High chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in ANGLE
DescriptionImproper input validation in ANGLE
ComponentANGLE
Sub-componentD3D11
Bug ClassInput Validation
Tracker564064199
Fix commit0b43596f5117 (angle/angle) +133/-32
CISA KEVNot listed
CreditedJinpyo Lee
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
BPTCCompressedTextureTest
src/tests/gl_tests/BPTCCompressedTextureTest.cpp
modified

Files Changed

  • src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
  • src/libANGLE/renderer/d3d/d3d11/renderer11_utils.cpp
  • src/tests/gl_tests/BPTCCompressedTextureTest.cpp
  • src/libANGLE/renderer/d3d/d3d11/Image11.cpp
  • src/libANGLE/renderer/d3d/d3d11/renderer11_utils.h
  • src/tests/gl_tests/D3D11FormatTablesTest.cpp
From 0b43596f51170a3cbddc86ed9913efee4fcaecea Mon Sep 17 00:00:00 2001
From: Ran Wang <[email protected]>
Date: Wed, 23 Sep 2026 10:34:51 -0400
Subject: [PATCH] D3D11: Expand 3D texture depth in MakeValidSize

When d3d11::MakeValidSize expands sub-block compressed mip dimensions
by levelOffset, also shift requestDepth for 3D textures so the selected
D3D11 mip subresource has mDepth slices.

Test:
    angle_end2end_tests --gtest_filter="*BPTCCompressedTextureTestES3.CompressedTexImage3DSubBlockMipDepth/*"
Bug: b/564064199
Change-Id: I913ba2abcd631714b617ea8d835c9a2bcfb6f36a
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8451922
Reviewed-by: Shahbaz Youssefi <[email protected]>
Commit-Queue: Ran Wang <[email protected]>
Reviewed-by: Geoff Lang <[email protected]>
---
diff --git a/src/libANGLE/renderer/d3d/d3d11/Image11.cpp b/src/libANGLE/renderer/d3d/d3d11/Image11.cpp
index 58e0e68..8405546 100644
--- a/src/libANGLE/renderer/d3d/d3d11/Image11.cpp
+++ b/src/libANGLE/renderer/d3d/d3d11/Image11.cpp
@@ -608,9 +608,10 @@
     int lodOffset  = 1;
     GLsizei width  = mWidth;
     GLsizei height = mHeight;
+    GLsizei depth  = mDepth;
 
     // adjust size if needed for compressed textures
-    d3d11::MakeValidSize(false, dxgiFormat, &width, &height, &lodOffset);
+    d3d11::MakeValidSize(false, dxgiFormat, mType, width, height, depth, lodOffset);
 
     Context11 *context11 = GetImplAs<Context11>(context);
 
@@ -621,7 +622,7 @@
             D3D11_TEXTURE3D_DESC desc;
             desc.Width          = width;
             desc.Height         = height;
-            desc.Depth          = mDepth;
+            desc.Depth          = depth;
             desc.MipLevels      = lodOffset + 1;
             desc.Format         = dxgiFormat;
             desc.Usage          = D3D11_USAGE_STAGING;
@@ -634,7 +635,7 @@
                 gl::TexLevelArray<D3D11_SUBRESOURCE_DATA> initialData;
                 ANGLE_TRY(d3d11::GenerateInitialTextureData(
                     context, mInternalFormat, mRenderer->getRenderer11DeviceCaps(), width, height,
-                    mDepth, lodOffset + 1, &initialData));
+                    depth, lodOffset + 1, &initialData));
 
                 ANGLE_TRY(mRenderer->allocateTexture(context11, desc, formatInfo,
                                                      initialData.data(), &mStagingTexture));
diff --git a/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp b/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
index c192c80..680ee21 100644
--- a/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
+++ b/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
@@ -984,11 +984,13 @@
         mRenderTarget[i]     = nullptr;
     }
 
-    d3d11::MakeValidSize(false, mFormatInfo.texFormat, &width, &height, &mTopLevel);
+    GLsizei depth = 1;
+    d3d11::MakeValidSize(false, mFormatInfo.texFormat, gl::TextureType::_2D, width, height, depth,
+                         mTopLevel);
     mMipLevels     = mTopLevel + levels;
     mTextureWidth  = width;
     mTextureHeight = height;
-    mTextureDepth  = 1;
+    mTextureDepth  = depth;
 }
 
 void TextureStorage11_2D::onLabelUpdate()
@@ -1922,13 +1924,15 @@
     }
 
     // adjust size if needed for compressed textures
-    int height = size;
-    d3d11::MakeValidSize(false, mFormatInfo.texFormat, &size, &height, &mTopLevel);
+    int height    = size;
+    GLsizei depth = 1;
+    d3d11::MakeValidSize(false, mFormatInfo.texFormat, gl::TextureType::CubeMap, size, height,
+                         depth, mTopLevel);
 
     mMipLevels     = mTopLevel + levels;
     mTextureWidth  = size;
     mTextureHeight = size;
-    mTextureDepth  = 1;
+    mTextureDepth  = depth;
 }
 
 angle::Result TextureStorage11_Cube::onDestroy(const gl::Context *context)
@@ -2433,7 +2437,8 @@
     }
 
     // adjust size if needed for compressed textures
-    d3d11::MakeValidSize(false, mFormatInfo.texFormat, &width, &height, &mTopLevel);
+    d3d11::MakeValidSize(false, mFormatInfo.texFormat, gl::TextureType::_3D, width, height, depth,
+                         mTopLevel);
 
     mMipLevels     = mTopLevel + levels;
     mTextureWidth  = width;
@@ -2810,7 +2815,8 @@
           label)
 {
     // adjust size if needed for compressed textures
-    d3d11::MakeValidSize(false, mFormatInfo.texFormat, &width, &height, &mTopLevel);
+    d3d11::MakeValidSize(false, mFormatInfo.texFormat, gl::TextureType::_2DArray, width, height,
+                         depth, mTopLevel);
 
     mMipLevels     = mTopLevel + levels;
     mTextureWidth  = width;
diff --git a/src/libANGLE/renderer/d3d/d3d11/renderer11_utils.cpp b/src/libANGLE/renderer/d3d/d3d11/renderer11_utils.cpp
index e37c005..a0feee9 100644
--- a/src/libANGLE/renderer/d3d/d3d11/renderer11_utils.cpp
+++ b/src/libANGLE/renderer/d3d/d3d11/renderer11_utils.cpp
@@ -1859,9 +1859,11 @@
 
 void MakeValidSize(bool isImage,
                    DXGI_FORMAT format,
-                   GLsizei *requestWidth,
-                   GLsizei *requestHeight,
-                   int *levelOffset)
+                   gl::TextureType type,
+                   GLsizei &requestWidth,
+                   GLsizei &requestHeight,
+                   GLsizei &requestDepth,
+                   int &levelOffset)
 {
     const DXGIFormatSize &dxgiFormatInfo = d3d11::GetDXGIFormatSizeInfo(format);
     bool validFormat                     = format != DXGI_FORMAT_UNKNOWN;
@@ -1869,35 +1871,36 @@
 
     int upsampleCount = 0;
     // Don't expand the size of full textures that are at least (blockWidth x blockHeight) already.
-    if (validImage || *requestWidth < static_cast<GLsizei>(dxgiFormatInfo.blockWidth) ||
-        *requestHeight < static_cast<GLsizei>(dxgiFormatInfo.blockHeight))
+    if (validImage || requestWidth < static_cast<GLsizei>(dxgiFormatInfo.blockWidth) ||
+        requestHeight < static_cast<GLsizei>(dxgiFormatInfo.blockHeight))
     {
-        while (*requestWidth % dxgiFormatInfo.blockWidth != 0 ||
-               *requestHeight % dxgiFormatInfo.blockHeight != 0)
+        while (requestWidth % dxgiFormatInfo.blockWidth != 0 ||
+               requestHeight % dxgiFormatInfo.blockHeight != 0)
         {
-            *requestWidth <<= 1;
-            *requestHeight <<= 1;
+            requestWidth <<= 1;
+            requestHeight <<= 1;
+            if (type == gl::TextureType::_3D)
+            {
+                requestDepth <<= 1;
+            }
             upsampleCount++;
         }
     }
     else if (validFormat)
     {
-        if (*requestWidth % dxgiFormatInfo.blockWidth != 0)
+        if (requestWidth % dxgiFormatInfo.blockWidth != 0)
         {
-            *requestWidth = roundUp(*requestWidth, static_cast<GLsizei>(dxgiFormatInfo.blockWidth));
+            requestWidth = roundUp(requestWidth, static_cast<GLsizei>(dxgiFormatInfo.blockWidth));
         }
 
-        if (*requestHeight % dxgiFormatInfo.blockHeight != 0)
+        if (requestHeight % dxgiFormatInfo.blockHeight != 0)
         {
-            *requestHeight =
-                roundUp(*requestHeight, static_cast<GLsizei>(dxgiFormatInfo.blockHeight));
+            requestHeight =
+                roundUp(requestHeight, static_cast<GLsizei>(dxgiFormatInfo.blockHeight));
         }
     }
 
-    if (levelOffset)
-    {
-        *levelOffset = upsampleCount;
-    }
+    levelOffset = upsampleCount;
 }
 
 angle::Result GenerateInitialTextureData(
diff --git a/src/libANGLE/renderer/d3d/d3d11/renderer11_utils.h b/src/libANGLE/renderer/d3d/d3d11/renderer11_utils.h
index 0bdc4bc..aba6d98 100644
--- a/src/libANGLE/renderer/d3d/d3d11/renderer11_utils.h
+++ b/src/libANGLE/renderer/d3d/d3d11/renderer11_utils.h
@@ -102,9 +102,11 @@
 
 void MakeValidSize(bool isImage,
                    DXGI_FORMAT format,
-                   GLsizei *requestWidth,
-                   GLsizei *requestHeight,
-                   int *levelOffset);
+                   gl::TextureType type,
+                   GLsizei &requestWidth,
+                   GLsizei &requestHeight,
+                   GLsizei &requestDepth,
+                   int &levelOffset);
 
 angle::Result GenerateInitialTextureData(
     const gl::Context *context,
diff --git a/src/tests/gl_tests/BPTCCompressedTextureTest.cpp b/src/tests/gl_tests/BPTCCompressedTextureTest.cpp
index 3e547fc..c093179 100644
--- a/src/tests/gl_tests/BPTCCompressedTextureTest.cpp
+++ b/src/tests/gl_tests/BPTCCompressedTextureTest.cpp
@@ -25,6 +25,17 @@
 // Sampling from a zero-filled block is undefined, so use a valid one.
 const std::array<GLubyte, 16> kBC7BlackData4x4 = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
                                                   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+// BC7 Mode 6 packs fields LSB-first across 128 bits:
+//   bits [0..6]   = 0b1000000 (Mode 6: 7-bit RGBA endpoints + 1-bit p-bit)
+//   bits [7..13]  = R0, [14..20] = R1, [21..27] = G0, [28..34] = G1,
+//   bits [35..41] = B0, [42..48] = B1, [49..55] = A0, [56..62] = A1,
+//   bit  [63]     = P0, [64]     = P1, [65..127] = 16 texel indices (all 0 -> endpoint 0).
+// Mode 6 with R0=0x7f (bits 7..13 -> byte 0 bit 7 = 0x80, byte 1 bits 0..5 = 0x3f),
+// A0=0x7f (bits 49..55 -> byte 6 bits 1..7 = 0xfe), and P0=1 (bit 63 -> byte 7 = 0x80)
+// -> endpoint 0 is solid RGBA(255, 0, 0, 255) (red).
+constexpr std::array<GLubyte, 16> kBC7RedData4x4 = {0xc0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x80,
+                                                    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
 }  // anonymous namespace
 
 class BPTCCompressedTextureTest : public ANGLETest<>
@@ -446,6 +457,82 @@
     ASSERT_GL_ERROR(GL_INVALID_VALUE);
 }
 
+// Test that uploading and sampling 3D BPTC mip levels smaller than a 4x4 block with depth > 1
+// allocates a staging texture with sufficient depth at the selected mip subresource.
+TEST_P(BPTCCompressedTextureTestES3, CompressedTexImage3DSubBlockMipDepth)
+{
+    ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_texture_compression_bptc"));
+
+    constexpr char kVS[] = R"(#version 300 es
+in vec4 a_position;
+void main()
+{
+    gl_Position = a_position;
+})";
+    constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+precision highp sampler3D;
+uniform sampler3D u_tex;
+uniform float u_z;
+out vec4 my_FragColor;
+void main()
+{
+    my_FragColor = texture(u_tex, vec3(0.5, 0.5, u_z));
+})";
+    ANGLE_GL_PROGRAM(program, kVS, kFS);
+    glUseProgram(program);
+    GLint zLoc = glGetUniformLocation(program, "u_z");
+    ASSERT_NE(-1, zLoc);
+
+    auto testSubBlockMipUploadAndSample = [&](GLint level, GLsizei width, GLsizei height,
+                                              GLsizei depth) {
+        GLTexture tex;
+        glBindTexture(GL_TEXTURE_3D, tex);
+        glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
+        glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+        glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_BASE_LEVEL, level);
+        glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAX_LEVEL, level);
+
+        const size_t blocksPerSlice =
+            static_cast<size_t>((width + 3) / 4) * static_cast<size_t>((height + 3) / 4);
+        const size_t totalBlocks = blocksPerSlice * static_cast<size_t>(depth);
+        std::vector<GLubyte> data;
+        data.reserve(totalBlocks * 16);
+
+        // Fill slices [0, depth - 2] with transparentBlack and the last slice (depth - 1) with red.
+        for (size_t block = 0; block < totalBlocks; ++block)
+        {
+            const auto &blockData = (block < blocksPerSlice * static_cast<size_t>(depth - 1))
+                                        ? kBC7BlackData4x4
+                                        : kBC7RedData4x4;
+            data.insert(data.end(), blockData.begin(), blockData.end());
+        }
+
+        glCompressedTexImage3D(GL_TEXTURE_3D, level, GL_COMPRESSED_RGBA_BPTC_UNORM_EXT, width,
+                               height, depth, 0, static_cast<GLsizei>(data.size()), data.data());
+        ASSERT_GL_NO_ERROR();
+
+        glUniform1f(zLoc, 0.5f / static_cast<float>(depth));
+        drawQuad(program, "a_position", 0.5f);
+        EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor::transparentBlack, kPixelTolerance);
+
+        glUniform1f(zLoc, (static_cast<float>(depth) - 0.5f) / static_cast<float>(depth));
+        drawQuad(program, "a_position", 0.5f);
+        EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor::red, kPixelTolerance);
+    };
+
+    // Level 1 at 1024x2x1024 (height < 4 -> lodOffset = 1): inputRowPitch (4096) != mapped D3D11
+    // DepthPitch (32768), exercising the strided per-slice/per-row loop in LoadCompressedToNative.
+    // Without expanding staging depth by lodOffset, mip 1 only has 512 physical slices instead of
+    // 1024, writing 512 slices across a 16 MiB out-of-bounds span.
+    testSubBlockMipUploadAndSample(1, 1024, 2, 1024);
+
+    // Level 2 at 1x1x512 (width < 4, height < 4 -> lodOffset = 2): both inputDepthPitch and
+    // mapped D3D11 DepthPitch are 16 bytes, exercising the single contiguous memcpy fast-path
+    // (inputImageSize == outputImageSize) in LoadCompressedToNative.
+    testSubBlockMipUploadAndSample(2, 1, 1, 512);
+}
+
 // Use this to select which configurations (e.g. which renderer, which GLES major version) these
 // tests should be run against.
 ANGLE_INSTANTIATE_TEST_ES2_AND_ES3(BPTCCompressedTextureTest);
diff --git a/src/tests/gl_tests/D3D11FormatTablesTest.cpp b/src/tests/gl_tests/D3D11FormatTablesTest.cpp
index 0583cb2..8613f19 100644
--- a/src/tests/gl_tests/D3D11FormatTablesTest.cpp
+++ b/src/tests/gl_tests/D3D11FormatTablesTest.cpp
@@ -180,9 +180,11 @@
         {
             int reqWidth  = 32;
             int reqHeight = 32;
+            int reqDepth  = 32;
             int level     = 0;
 
-            rx::d3d11::MakeValidSize(image, formatInfo.texFormat, &reqWidth, &reqHeight, &level);
+            rx::d3d11::MakeValidSize(image, formatInfo.texFormat, gl::TextureType::_3D, reqWidth,
+                                     reqHeight, reqDepth, level);
         }
     }
 }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/BPTCCompressedTextureTest.cpp b/src/tests/gl_tests/BPTCCompressedTextureTest.cpp
index 3e547fc..c093179 100644
--- a/src/tests/gl_tests/BPTCCompressedTextureTest.cpp
+++ b/src/tests/gl_tests/BPTCCompressedTextureTest.cpp
@@ -25,6 +25,17 @@
 // Sampling from a zero-filled block is undefined, so use a valid one.
 const std::array<GLubyte, 16> kBC7BlackData4x4 = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
                                                   0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+// BC7 Mode 6 packs fields LSB-first across 128 bits:
+//   bits [0..6]   = 0b1000000 (Mode 6: 7-bit RGBA endpoints + 1-bit p-bit)
+//   bits [7..13]  = R0, [14..20] = R1, [21..27] = G0, [28..34] = G1,
+//   bits [35..41] = B0, [42..48] = B1, [49..55] = A0, [56..62] = A1,
+//   bit  [63]     = P0, [64]     = P1, [65..127] = 16 texel indices (all 0 -> endpoint 0).
+// Mode 6 with R0=0x7f (bits 7..13 -> byte 0 bit 7 = 0x80, byte 1 bits 0..5 = 0x3f),
+// A0=0x7f (bits 49..55 -> byte 6 bits 1..7 = 0xfe), and P0=1 (bit 63 -> byte 7 = 0x80)
+// -> endpoint 0 is solid RGBA(255, 0, 0, 255) (red).
+constexpr std::array<GLubyte, 16> kBC7RedData4x4 = {0xc0, 0x3f, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x80,
+                                                    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
 }  // anonymous namespace
 
 class BPTCCompressedTextureTest : public ANGLETest<>
@@ -446,6 +457,82 @@
     ASSERT_GL_ERROR(GL_INVALID_VALUE);
 }
 
+// Test that uploading and sampling 3D BPTC mip levels smaller than a 4x4 block with depth > 1
+// allocates a staging texture with sufficient depth at the selected mip subresource.
+TEST_P(BPTCCompressedTextureTestES3, CompressedTexImage3DSubBlockMipDepth)
+{
+    ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_texture_compression_bptc"));
+
+    constexpr char kVS[] = R"(#version 300 es
+in vec4 a_position;
+void main()
+{
+    gl_Position = a_position;
+})";
+    constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+precision highp sampler3D;
+uniform sampler3D u_tex;
+uniform float u_z;
+out vec4 my_FragColor;
+void main()
+{
+    my_FragColor = texture(u_tex, vec3(0.5, 0.5, u_z));
+})";
+    ANGLE_GL_PROGRAM(program, kVS, kFS);
+    glUseProgram(program);
+    GLint zLoc = glGetUniformLocation(program, "u_z");
+    ASSERT_NE(-1, zLoc);
+
+    auto testSubBlockMipUploadAndSample = [&](GLint level, GLsizei width, GLsizei height,
+                                              GLsizei depth) {
+        GLTexture tex;
+        glBindTexture(GL_TEXTURE_3D, tex);
+        glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
+        glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+        glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_BASE_LEVEL, level);
+        glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAX_LEVEL, level);
+
+        const size_t blocksPerSlice =
+            static_cast<size_t>((width + 3) / 4) * static_cast<size_t>((height + 3) / 4);
+        const size_t totalBlocks = blocksPerSlice * static_cast<size_t>(depth);
+        std::vector<GLubyte> data;
+        data.reserve(totalBlocks * 16);
+
+        // Fill slices [0, depth - 2] with transparentBlack and the last slice (depth - 1) with red.
+        for (size_t block = 0; block < totalBlocks; ++block)
+        {
+            const auto &blockData = (block < blocksPerSlice * static_cast<size_t>(depth - 1))
+                                        ? kBC7BlackData4x4
+                                        : kBC7RedData4x4;
+            data.insert(data.end(), blockData.begin(), blockData.end());
+        }
+
+        glCompressedTexImage3D(GL_TEXTURE_3D, level, GL_COMPRESSED_RGBA_BPTC_UNORM_EXT, width,
+                               height, depth, 0, static_cast<GLsizei>(data.size()), data.data());
+        ASSERT_GL_NO_ERROR();
+
+        glUniform1f(zLoc, 0.5f / static_cast<float>(depth));
+        drawQuad(program, "a_position", 0.5f);
+        EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor::transparentBlack, kPixelTolerance);
+
+        glUniform1f(zLoc, (static_cast<float>(depth) - 0.5f) / static_cast<float>(depth));
+        drawQuad(program, "a_position", 0.5f);
+        EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor::red, kPixelTolerance);
+    };
+
+    // Level 1 at 1024x2x1024 (height < 4 -> lodOffset = 1): inputRowPitch (4096) != mapped D3D11
+    // DepthPitch (32768), exercising the strided per-slice/per-row loop in LoadCompressedToNative.
+    // Without expanding staging depth by lodOffset, mip 1 only has 512 physical slices instead of
+    // 1024, writing 512 slices across a 16 MiB out-of-bounds span.
+    testSubBlockMipUploadAndSample(1, 1024, 2, 1024);
+
+    // Level 2 at 1x1x512 (width < 4, height < 4 -> lodOffset = 2): both inputDepthPitch and
+    // mapped D3D11 DepthPitch are 16 bytes, exercising the single contiguous memcpy fast-path
+    // (inputImageSize == outputImageSize) in LoadCompressedToNative.
+    testSubBlockMipUploadAndSample(2, 1, 1, 512);
+}
+
 // Use this to select which configurations (e.g. which renderer, which GLES major version) these
 // tests should be run against.
 ANGLE_INSTANTIATE_TEST_ES2_AND_ES3(BPTCCompressedTextureTest);
diff --git a/src/tests/gl_tests/D3D11FormatTablesTest.cpp b/src/tests/gl_tests/D3D11FormatTablesTest.cpp
index 0583cb2..8613f19 100644
--- a/src/tests/gl_tests/D3D11FormatTablesTest.cpp
+++ b/src/tests/gl_tests/D3D11FormatTablesTest.cpp
@@ -180,9 +180,11 @@
         {
             int reqWidth  = 32;
             int reqHeight = 32;
+            int reqDepth  = 32;
             int level     = 0;
 
-            rx::d3d11::MakeValidSize(image, formatInfo.texFormat, &reqWidth, &reqHeight, &level);
+            rx::d3d11::MakeValidSize(image, formatInfo.texFormat, gl::TextureType::_3D, reqWidth,
+                                     reqHeight, reqDepth, level);
         }
     }
 }
Loading diff…

Original Bug Report

reported by [email protected]

ANGLE D3D11 compressed 3D staging-mip depth mismatch causes WebGL GPU-process OOB write


Report description

ANGLE D3D11 compressed 3D staging-mip depth mismatch causes WebGL GPU-process OOB write


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/angle/angle


The problem

Please describe the technical details of the vulnerability

Summary

A valid WebGL2 compressedTexImage3D call using EXT_texture_compression_bptc causes ANGLE’s D3D11 backend to copy attacker-controlled bytes beyond a mapped 3D staging subresource in Chrome’s sandboxed GPU process.

ANGLE expands the base width and height of a block-compressed image when a nonzero mip level is smaller than one 4x4 compression block. It then selects a synthetic lower D3D mip whose X and Y dimensions match the logical WebGL image. For a 3D texture, Image11::createStagingTexture leaves the base D3D depth equal to the logical depth. D3D11 reduces depth at the selected mip, but Image11::loadCompressedData still passes the complete logical depth to LoadCompressedToNative. The loader consequently writes two or four times the selected subresource’s physical depth.

Released Google Chrome Stable 153.0.8010.50 on Windows 11 x64 reproduces the vulnerability from ordinary web content using the default ANGLE D3D11 backend with the GPU sandbox enabled. No permission or user interaction beyond loading the page is required. The minimized 4 KiB PoC produces actionable GPU-process heap corruption. The maximum level-1 geometry makes 512 logical slices cross the physical-depth boundary, representing 2 MiB of attacker-controlled row data over an approximately 16 MiB out-of-bounds window. A symbolized production-Chrome crash captured the first 4,016 attacker-controlled bytes written beyond the selected subresource before memcpy reached an inaccessible page.

The strongest contiguous case is a valid level-2 1x1x512 BC7 upload. Chrome calls one 8,192-byte memcpy for a selected mip whose physical span is 2,048 bytes, producing a single 6,144-byte contiguous attacker-controlled out-of-bounds overwrite. On Intel Arc A350M, the copy completed across the boundary and replaced adjacent live heap objects with exact WebGL-supplied marker bytes. A fixed draw-materialized ARRAY_BUFFER layout reproduced live internal-entry replacement followed by consumption of attacker-derived qwords as pointers in 2/2 independent fresh GPU processes. A third fresh run reached the Intel D3D11 draw path directly: driver code loaded the exact copied qword 0x4242424242424242 from the overwritten region, added 0xa8, and dereferenced the resulting object pointer 0x42424242424242ea during the page’s first post-corruption draw.

The maximum level-1 case reproduced a GPU-process write access violation in 3/3 fresh profiles on the default Intel Iris Xe adapter. The maximum level-2 case replaced the GPU process in 2/2 fresh Chrome profiles on Intel Iris Xe and 2/2 fresh profiles on Intel Arc A350M. Microsoft Edge Stable 153.0.4234.48 independently reproduced the maximum level-2 failure in 2/2 fresh profiles with its default D3D11 backend.

The minimized candidate also reproduced in Chrome Stable with all four WebGL BPTC formats: BC7 UNORM, BC7 sRGB, BC6H signed float, and BC6H unsigned float. Every candidate returned GL_NO_ERROR before GPU-process termination, while the matching depth-one control for each format remained clean.

Root cause

The issue is present in released Chrome 153.0.8010.50, official Chromium ASAN 153.0.8010.0 using ANGLE revision b5e087aaabc65090cc75aa7f5a31a00ec11c7ed8, and reviewed ANGLE main revision 2bb28bbcef760ede0c084f4cf3341f495887f1f5.

The attacker-controlled path captured in the symbolized Stable crash is:

WebGL2 compressedTexImage3D
gpu::gles2::GLES2DecoderPassthroughImpl::HandleCompressedTexImage3DBucket
gpu::gles2::GLES2DecoderPassthroughImpl::DoCompressedTexImage3D
GL_CompressedTexImage3D
gl::Context::compressedTexImage3D
gl::Texture::setCompressedImage
rx::TextureD3D_3D::setCompressedImage
rx::TextureD3D::setCompressedImageImpl
rx::Image11::loadCompressedData
angle::LoadCompressedToNative<4, 4, 1, 16>
memcpy

Image11::createStagingTexture in src/libANGLE/renderer/d3d/d3d11/Image11.cpp begins with the logical width and height and passes them to MakeValidSize:

int lodOffset  = 1;
GLsizei width  = mWidth;
GLsizei height = mHeight;

d3d11::MakeValidSize(false, dxgiFormat, &width, &height, &lodOffset);

For BC7 at logical level 1 with width 2 and height 2, MakeValidSize changes the base dimensions to 4x4 and returns lodOffset=1. For logical level 2 with width 1 and height 1, it changes the base dimensions to 4x4 and returns lodOffset=2.

The 3D staging descriptor uses the expanded width and height but does not expand depth:

D3D11_TEXTURE3D_DESC desc;
desc.Width          = width;
desc.Height         = height;
desc.Depth          = mDepth;
desc.MipLevels      = lodOffset + 1;
desc.Format         = dxgiFormat;
desc.Usage          = D3D11_USAGE_STAGING;
desc.BindFlags      = 0;
desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ | D3D11_CPU_ACCESS_WRITE;
desc.MiscFlags      = 0;

mStagingSubresource = D3D11CalcSubresource(lodOffset, 0, lodOffset + 1);

D3D11 reduces all three dimensions at each mip. The selected subresource therefore has physical depth max(1, mDepth >> lodOffset), while ANGLE requires space for mDepth logical slices.

Image11::loadCompressedData maps that selected subresource and invokes the format loader with area.depth, which remains the complete logical WebGL depth:

D3D11_MAPPED_SUBRESOURCE mappedImage;
ANGLE_TRY(map(context, D3D11_MAP_WRITE, &mappedImage));

uint8_t *offsetMappedData = ANGLE_UNSAFE_TODO(
    static_cast<uint8_t *>(mappedImage.pData) +
    ((area.y / outputBlockHeight) * mappedImage.RowPitch +
     (area.x / outputBlockWidth) * outputPixelSize + area.z * mappedImage.DepthPitch));

loadFunction(context11->getImageLoadContext(), area.width, area.height, area.depth,
             static_cast<const uint8_t *>(input), inputRowPitch, inputDepthPitch,
             offsetMappedData, mappedImage.RowPitch, mappedImage.DepthPitch);

LoadCompressedToNative in src/image_util/loadimage.inc derives its complete copy length and loop count from that logical depth without receiving the selected subresource’s physical depth:

const size_t columns = (width + (blockWidth - 1)) / blockWidth;
const size_t rows = (height + (blockHeight - 1)) / blockHeight;
const size_t layers = (depth + (blockDepth - 1)) / blockDepth;

const size_t inputLayerSize = inputRowPitch * rows;
const size_t inputImageSize = inputDepthPitch * layers;

const size_t outputLayerSize = outputRowPitch * rows;
const size_t outputImageSize = outputDepthPitch * layers;

if (inputImageSize == outputImageSize)
{
    ASSERT(inputRowPitch == outputRowPitch);
    ASSERT(inputLayerSize == outputLayerSize && inputLayerSize == inputDepthPitch &&
           outputLayerSize == outputDepthPitch);
    memcpy(output, input, inputImageSize);
}
else
{
    for (size_t z = 0; z < layers; ++z)
    {
        for (size_t y = 0; y < rows; ++y)
        {
            const uint8_t *source = priv::OffsetDataPointer<uint8_t>(
                input, y, z, inputRowPitch, inputDepthPitch);
            uint8_t *dest = priv::OffsetDataPointer<uint8_t>(
                output, y, z, outputRowPitch, outputDepthPitch);
            memcpy(dest, source, columns * blockSize);
        }
    }
}

The WebGL validation accepts these dimensions. EXT_texture_compression_bptc supports 3D textures, and the compressed-image validation checks that the implied level-zero width and height are block aligned. Level 1 at 2x2 is accepted because 2 << 1 is 4, and level 2 at 1x1 is accepted because 1 << 2 is 4. Chrome returns GL_NO_ERROR for the vulnerable uploads.

For level 1 at 1024x2x1024, the input row and depth pitches are 4,096 bytes, while the mapped D3D row and depth pitches are 16,384 and 32,768 bytes. The selected mip has physical depth 512, but the loader uses 1,024 slices. Slices 512 through 1023 each direct a 4,096-byte attacker-controlled row beyond the physical subresource, totaling 2,097,152 attacker bytes. The first OOB row in the preserved Stable dump started exactly at the physical-depth boundary and wrote 4,016 supplied 0x40 bytes before the faulting vector store reached the next inaccessible page.

For level 2 at 1x1x512, the live descriptor is 4x4x512 with three mips. Mip 2 is physically 1x1x128. Both input and output depth pitches are 16 bytes, so inputImageSize and outputImageSize are both 8,192 and the single-copy branch executes memcpy(output, input, 8192). The selected subresource has only 2,048 bytes. The remaining 6,144 bytes overwrite memory after the allocation in one contiguous copy.

The production-Chrome debugger captured the completed copy and its consumers. In the first fresh run, a live 0x400-byte internal entry inside the OOB interval was replaced by the exact per-slice WebGL markers. RtlFreeHeap then loaded 0x6d6d6d6d6d6d6d6d from the overwritten entry and dereferenced it. In the independent repeat, a live 0x410-byte internal entry began exactly at the physical end. The copy replaced it with 0x40, 0x41, and subsequent markers; the LFH path then used rdx=0x4141414141414141 and r8=0x4141414141414140 for a pointer dereference. A fixed-source control changed the compressed source to 0x55 and independently changed the overwritten live-entry bytes to 0x55.

In the direct draw-consumer run, the page performed the same corrupting call and immediately drew from the retained buffers. Intel driver code executed this sequence:

mov r15, qword ptr [rdx]
add r15, 0x0f
and r15, -0x10
mov r13, qword ptr [r15]
add r13, 0xa8
mov ecx, dword ptr [r13+0x590]

r15 resolved to output+0xc20. Memory at that address and the corresponding source offset both contained 0x4242424242424242. The live register value was r13=0x42424242424242ea when the driver dereferenced it. The stack connects the fault through d3d11!CContext::Draw, rx::Renderer11::drawArrays, GL_DrawArrays, and GLES2DecoderPassthroughImpl::DoDrawArrays.

Changing only the live descriptor Depth from 0x200 to 0x800 expanded the output heap entry from 0x820 to 0x2020. The unchanged 0x2000-byte copy then ended exactly at the allocation boundary, the following heap entry remained valid, all allocation, materialization, post-upload draw, deletion, upload, and finish results were GL_NO_ERROR, contextLost remained false, and the GPU PID remained unchanged. This one-field control confirms that the missing depth expansion is the root cause and that expanding the base depth by lodOffset keeps the copy within the staging allocation.

Reproduction environment

Google Chrome Stable 153.0.8010.50 (Official Build), 64-bit Windows 11 Home x64, version 10.0.26200, build 26200 Intel Iris Xe, PCI VEN_8086&DEV_46A6, driver 30.0.101.1747 Intel Arc A350M, PCI VEN_8086&DEV_5694, driver 30.0.101.1747 Primary renderer: ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x000046A6) Direct3D11 vs_5_0 ps_5_0, D3D11) Secondary renderer: ANGLE (Intel, Intel(R) Arc(TM) A350M Graphics (0x00005694) Direct3D11 vs_5_0 ps_5_0, D3D11) GL implementation: ANGLE D3D11 MAX_3D_TEXTURE_SIZE: 2048 GPU process: sandboxed and out of process

The primary reproduction used Chrome’s default D3D11 backend and default Iris Xe adapter. No –use-angle, –use-gl, –no-sandbox, –disable-gpu-sandbox, –ignore-gpu-blocklist, –single-process, or in-process-GPU flag was used. Fresh profiles were used to isolate repeated runs and are not required for the trigger.

The maximum level-1 candidate reproduced a direct GPU-process write access violation in 3/3 fresh-profile runs on the default adapter. The level-2 maximum candidate replaced the GPU process in 2/2 fresh profiles on Iris Xe and 2/2 on Arc A350M. The level-2 contiguous candidate replaced the GPU process in 2/2 fresh Arc profiles. The fixed draw-buffer layout produced live-object overwrite and attacker-derived pointer consumption in 2/2 independent fresh Arc processes. The depth-one and level-zero boundary controls completed with GL_NO_ERROR, no context loss, and no GPU-process replacement.

PoC

Place the attached poc.html and poc_live_object.html in one directory and run:

python3 -m http.server 8000 --bind 0.0.0.0

Open the maximum direct-write case in Chrome Stable:

http://127.0.0.1:8000/poc.html?max=1

The page creates a WebGL2 context, enables EXT_texture_compression_bptc, and submits a valid level-1 BC7 TEXTURE_3D upload with dimensions 1024x2x1024. The page records uploadError=0 and finishError=0 before the GPU-process failure. Chrome then reports WebGL context loss and starts a replacement GPU process.

A successful reproduction contains:

max: true
control: false
width: 1024
height: 2
depth: 1024
stage: delayed-check
contextLost: true
uploadError: 0
finishError: 0

Chrome crash report ID: abf4afd32f800000

The attached ordinary-max-width-direct-write-cdb-sanitized.txt is a fully symbolized CDB trace from the Stable GPU process. The destination is a D3D-driver-owned mapped allocation, so the official ASAN build does not instrument the selected-subresource boundary. The release trace records the faulting write, allocation boundary, and exact attacker-controlled source and destination bytes.

The preserved symbolized CDB trace reports:

Failure.Bucket: INVALID_POINTER_WRITE_c0000005_chrome.dll!memcpy
ExceptionCode: c0000005
Attempt to write to address 0000015fd3593000

chrome!memcpy+0x2ea
chrome!angle::LoadCompressedToNative<4,4,1,16>+0xca
chrome!rx::Image11::loadCompressedData+0x1b4
chrome!rx::TextureD3D::setCompressedImageImpl+0xe7
chrome!rx::TextureD3D_3D::setCompressedImage+0x53
chrome!gl::Texture::setCompressedImage+0x102
chrome!gl::Context::compressedTexImage3D+0xfa
chrome!GL_CompressedTexImage3D+0x11a
chrome!gpu::gles2::GLES2DecoderPassthroughImpl::DoCompressedTexImage3D+0x97
chrome!gpu::gles2::GLES2DecoderPassthroughImpl::HandleCompressedTexImage3DBucket+0x9a

The first OOB destination is the exact physical-depth boundary. The row length is 0x1000, and 0xfb0 bytes were written before the page boundary. The debugger verified the supplied 0x40 marker in both the source and the already-written OOB destination.

Open the minimized case at:

http://127.0.0.1:8000/poc.html

This submits a 4 KiB level-1 2x2x256 input and produces GPU-process memory-corruption termination and WebGL context loss.

Open the fixed live-object layout on the Arc adapter at:

http://127.0.0.1:8000/poc_live_object.html

The page allocates 16 alternating 0x2000-byte filler and victim ARRAY_BUFFER pairs, draws from every buffer to materialize the D3D paths, deletes only the fillers, performs a valid level-2 1x1x512 upload, and immediately draws from every retained victim. The actual sink is one 0x2000-byte memcpy into a selected mip with a 0x800-byte physical span. The completed copy overwrites 0x1800 contiguous bytes past the subresource. Two independent fresh-process debugger runs captured complete live internal-entry replacement and subsequent pointer consumption from exact WebGL markers.

When Arc A350M is the secondary installed adapter, launch Chrome with –force-high-performance-gpu to select it. The primary poc.html?max=1 reproduction uses the default Iris Xe adapter without an adapter-selection flag.

For the maximum level-1 depth control, open a separate fresh profile at:

http://127.0.0.1:8000/poc.html?max=1&control=1

This changes only the logical depth from 1024 to 1. It completes with GL_NO_ERROR, contextLost=false, and no GPU-process exit.

For the live-object depth control, open a separate fresh profile at:

http://127.0.0.1:8000/poc_live_object.html?control=1

This keeps the buffer layout, draw sequence, level, width, and height unchanged and changes only the upload depth from 512 to 1. It completes with every recorded GL error equal to zero, contextLost=false, and no GPU-process exit.

Expected behavior: ANGLE must allocate a staging resource whose selected D3D subresource covers the complete logical WebGL width, height, and depth, and compressedTexImage3D must not write outside the mapped subresource.

Actual behavior: ANGLE expands only the base width and height, selects a mip whose depth is smaller than the logical WebGL depth, and copies the complete attacker-controlled logical depth into it. Released Chrome writes attacker-controlled bytes beyond the D3D staging subresource, corrupts adjacent heap memory and live internal objects, consumes overwritten qwords as pointers, and terminates the sandboxed GPU process.

Impact analysis

A remote attacker can trigger attacker-controlled memory corruption in Chrome’s sandboxed GPU process from ordinary WebGL2 content. The attack requires only that a user load the page. It uses a valid EXT_texture_compression_bptc compressedTexImage3D call on Chrome’s default shipping ANGLE D3D11 backend, with the GPU sandbox and normal process isolation enabled.

The vulnerable copy is a CPU write performed by chrome!memcpy through ANGLE’s LoadCompressedToNative path. The attacker controls every copied BC block and the logical geometry that determines the copy footprint. The maximum level-1 geometry crosses the allocation boundary with 512 attacker-controlled 4,096-byte rows, totaling 2 MiB of attacker content across an approximately 16 MiB out-of-bounds window. The maximum level-2 geometry directs 786,432 attacker bytes across 384 OOB slices and reaches more than 3 MiB beyond the physical end. The equal-pitch level-2 geometry performs a completed 8,192-byte copy with 6,144 contiguous attacker-controlled bytes beyond a 2,048-byte subresource.

The memory corruption reaches live process objects. The fixed ARRAY_BUFFER layout reproduced complete overwrite of a live internal heap entry in 2/2 independent fresh processes. In both runs, subsequent allocator or driver activity loaded exact WebGL marker qwords from the overwritten entry and used them as pointers. The first run dereferenced 0x6d6d6d6d6d6d6d6d. The repeat derived and dereferenced 0x4141414141414140 from an overwritten 0x41 block. Changing only the source to 0x55 changed the overwritten live-object bytes to 0x55, confirming direct attacker byte control.

The direct driver-consumer capture connects the overwrite to D3D rendering activity rather than only delayed heap validation. During the first post-corruption WebGL draw, Intel driver code loaded 0x4242424242424242 from the exact source-correlated OOB destination, transformed it into 0x42424242424242ea, and dereferenced it as an object pointer. The stack runs from the faulting Intel D3D11 user-mode driver through d3d11!CContext::Draw, ANGLE Renderer11::drawArrays, GL_DrawArrays, and the Chrome GLES decoder.

The issue is reliable across the tested physical D3D11 adapters and Chromium consumers. The default-adapter direct-write case reproduced in 3/3 fresh Chrome Stable profiles. Independent level-2 cases reproduced on Intel Iris Xe and Intel Arc A350M, and Microsoft Edge Stable reproduced the generalized failure on its default D3D11 path. Matching depth and mip controls remained clean.

The one-field repair provides a precise causality control. Expanding the descriptor depth by lodOffset changed the contiguous case’s allocation from 0x820 to 0x2020 bytes, kept the same 0x2000-byte attacker copy entirely inside the staging resource, preserved the following heap entry, completed all post-upload draws and deletions with GL_NO_ERROR, retained the same GPU PID, and kept the WebGL context live.


The cause

What version of Chrome have you found the security issue in?

153.0.8010.50 - Stable

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a sandboxed process)

How would you like to be publicly acknowledged for your report?

Jinpyo Lee

View on issue tracker