CVE-2026-28913
Overview
Background
- Block-compressed texture format
- Formats like ETC/ETC2 store fixed-size blocks covering multiple texels, so pitch is block-based, not pixelBytes*width.
- Row/depth pitch
- Byte stride between rows/slices of texture data; must match the format layout or copies go out of bounds.
- ANGLE Metal backend
- Implements WebGL/GLES on Apple Metal, including texture sub-image uploads.
- ANGLE_CHECK_GL_MATH
- Overflow-checked arithmetic used when computing compressed image pitches.
Root Cause Analysis
ANGLE’s Metal backend uploads sub-image texture data in TextureMtl::convertAndSetPerSliceSubImage. It computed the destination row and depth pitch as dstRowPitch = dstFormat.pixelBytes * mtlArea.size.width and dstDepthPitch = dstRowPitch * mtlArea.size.height. That formula is correct only for uncompressed (per-pixel) formats. For BLOCK-compressed formats (e.g. ETC/ETC2), data is laid out in fixed-size blocks covering several texels, so pixelBytes*width drastically underestimates the true row pitch; using that too-small pitch to index/copy the compressed sub-image writes and reads outside the allocated staging buffer — an out-of-bounds access surfacing as a process crash.
The fix branches on dstFormat.isBlock: for block formats it computes the row and depth pitch with the format’s block-aware helpers computeCompressedImageRowPitch() / computeCompressedImageDepthPitch(), guarded by ANGLE_CHECK_GL_MATH (overflow-checked), and only falls back to pixelBytes*width for uncompressed formats.
The restored invariant is that the destination pitch matches the actual (block-based) layout of the format being uploaded, so the copy stays within bounds.
Attack Path
- Use a compressed texture in WebGL On a Metal/ANGLE backend, upload a block-compressed texture (e.g. ETC2) via a sub-image update.
- Hit the per-slice convert path convertAndSetPerSliceSubImage computes dstRowPitch as pixelBytes*width, far smaller than the compressed layout needs.
- Under-sized pitch The copy uses the wrong pitch to index the destination, walking outside the staging buffer.
- Out-of-bounds / crash The mis-pitched copy reads/writes out of bounds in the GPU/WebContent process.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ETCToBCTextureTestSource/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp |
modified |
Files Changed
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mmSource/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp
Audit Directions
- Other pixelBytes*width pitchesgrep ANGLE metal/ for rowPitch/depthPitch computed as pixelBytes*width without an isBlock branch.
- Compressed-format pathsAudit texture upload/readback/copy code for compressed formats using per-pixel pitch math.
- Overflow checksEnsure compressed pitch computations use ANGLE_CHECK_GL_MATH so large dimensions can’t overflow the pitch.
Patch
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm
index b0c79d1ab4f6..00858ccfb89a 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm
@@ -2399,7 +2399,29 @@ angle::Result TextureMtl::convertAndSetPerSliceSubImage(const gl::Context *conte
? imageFormat.textureLoadFunctions(type)
: LoadImageFunctionInfo();
const angle::Format &dstFormat = angle::Format::Get(imageFormat.actualFormatId);
- const size_t dstRowPitch = dstFormat.pixelBytes * mtlArea.size.width;
+ size_t dstRowPitch;
+ size_t dstDepthPitch;
+ if (dstFormat.isBlock)
+ {
+ const gl::InternalFormat &dstFormatInfo =
+ gl::GetSizedInternalFormatInfo(dstFormat.glInternalFormat);
+ GLuint rowPitch;
+ ANGLE_CHECK_GL_MATH(contextMtl,
+ dstFormatInfo.computeCompressedImageRowPitch(
+ static_cast<GLsizei>(mtlArea.size.width), &rowPitch));
+ dstRowPitch = rowPitch;
+
+ GLuint depthPitch;
+ ANGLE_CHECK_GL_MATH(contextMtl, dstFormatInfo.computeCompressedImageDepthPitch(
+ static_cast<GLsizei>(mtlArea.size.height),
+ static_cast<GLuint>(dstRowPitch), &depthPitch));
+ dstDepthPitch = depthPitch;
+ }
+ else
+ {
+ dstRowPitch = dstFormat.pixelBytes * mtlArea.size.width;
+ dstDepthPitch = dstRowPitch * mtlArea.size.height;
+ }
// It is very important to avoid allocating a new buffer for each row during these
// uploads.
@@ -2413,7 +2435,6 @@ angle::Result TextureMtl::convertAndSetPerSliceSubImage(const gl::Context *conte
ASSERT(loadFunctionInfo.loadFunction);
// Need to create a buffer to hold entire decompressed image.
- const size_t dstDepthPitch = dstRowPitch * mtlArea.size.height;
angle::MemoryBuffer decompressBuf;
ANGLE_CHECK_GL_ALLOC(contextMtl,
decompressBuf.resize(dstDepthPitch * mtlArea.size.depth));
@@ -2437,7 +2458,6 @@ angle::Result TextureMtl::convertAndSetPerSliceSubImage(const gl::Context *conte
static_cast<unsigned int>(imageDef.image->sizeAt0().width));
ASSERT(mtlArea.size.height ==
static_cast<unsigned int>(imageDef.image->sizeAt0().height));
- const size_t dstDepthPitch = dstRowPitch * mtlArea.size.height;
ANGLE_TRY(UploadTextureContents(
context, dstFormat, mtlArea, mtl::kZeroNativeMipLevel, slice, pixels,
dstRowPitch, dstDepthPitch, kAvoidStagingBuffers, imageDef.image));
diff --git a/Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp b/Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp
index 319eb1c9ffe7..0d2f732676a4 100644
--- a/Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp
+++ b/Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp
@@ -12,6 +12,7 @@
#endif
#include "test_utils/ANGLETest.h"
+#include "test_utils/gl_raii.h"
#include "media/etc2bc_srgb8_alpha8.inc"
@@ -309,6 +310,45 @@ TEST_P(ETCTextureTest, ETC2SRGB8A1Validation)
}
}
+// Tests that uploading compressed texture from a PBO with a misaligned offset doesn't crash.
+TEST_P(ETCTextureTest, PBOWithMisalignedOffset)
+{
+ // Need ES 3.0 for PBOs.
+ ANGLE_SKIP_TEST_IF(getClientMajorVersion() < 3);
+
+ // Check if ETC2 is supported. Not supported, for example, on ANGLE/OpenGL on macOS.
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_ANGLE_lossy_etc_decode") &&
+ !IsGLExtensionEnabled("GL_OES_compressed_ETC2_RGB8_texture") &&
+ !IsGLExtensionEnabled("GL_ANGLE_compressed_texture_etc"));
+
+ constexpr GLsizei kWidth = 512;
+ constexpr GLsizei kHeight = 512;
+ constexpr GLsizei kBPB = 8; // 8 bytes per block
+ constexpr GLsizei kBW = 4;
+ constexpr GLsizei kBH = 4;
+
+ GLsizei blocksX = kWidth / kBW;
+ GLsizei blocksY = kHeight / kBH;
+ GLsizei compressedSize = blocksX * blocksY * kBPB;
+
+ glBindTexture(GL_TEXTURE_2D, mTexture);
+ // Use GL_COMPRESSED_RGB8_ETC2 which is core in ES 3.0
+ glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_COMPRESSED_RGB8_ETC2, kWidth, kHeight, 0,
+ compressedSize, nullptr);
+
+ // Misaligned offset to trigger the fallback path in Metal backend
+ constexpr GLsizei kPBOOffset = 1;
+
+ GLBuffer pbo;
+ glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo);
+ glBufferData(GL_PIXEL_UNPACK_BUFFER, kPBOOffset + compressedSize, nullptr, GL_STATIC_DRAW);
+ ASSERT_GL_NO_ERROR();
+
+ glCompressedTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kWidth, kHeight, GL_COMPRESSED_RGB8_ETC2,
+ compressedSize, reinterpret_cast<void *>(kPBOOffset));
+ EXPECT_GL_NO_ERROR();
+}
+
class ETCToBCTextureTest : public ANGLETest<>
{
protected:
Regression Test / PoC
diff --git a/Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp b/Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp
index 319eb1c9ffe7..0d2f732676a4 100644
--- a/Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp
+++ b/Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp
@@ -12,6 +12,7 @@
#endif
#include "test_utils/ANGLETest.h"
+#include "test_utils/gl_raii.h"
#include "media/etc2bc_srgb8_alpha8.inc"
@@ -309,6 +310,45 @@ TEST_P(ETCTextureTest, ETC2SRGB8A1Validation)
}
}
+// Tests that uploading compressed texture from a PBO with a misaligned offset doesn't crash.
+TEST_P(ETCTextureTest, PBOWithMisalignedOffset)
+{
+ // Need ES 3.0 for PBOs.
+ ANGLE_SKIP_TEST_IF(getClientMajorVersion() < 3);
+
+ // Check if ETC2 is supported. Not supported, for example, on ANGLE/OpenGL on macOS.
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_ANGLE_lossy_etc_decode") &&
+ !IsGLExtensionEnabled("GL_OES_compressed_ETC2_RGB8_texture") &&
+ !IsGLExtensionEnabled("GL_ANGLE_compressed_texture_etc"));
+
+ constexpr GLsizei kWidth = 512;
+ constexpr GLsizei kHeight = 512;
+ constexpr GLsizei kBPB = 8; // 8 bytes per block
+ constexpr GLsizei kBW = 4;
+ constexpr GLsizei kBH = 4;
+
+ GLsizei blocksX = kWidth / kBW;
+ GLsizei blocksY = kHeight / kBH;
+ GLsizei compressedSize = blocksX * blocksY * kBPB;
+
+ glBindTexture(GL_TEXTURE_2D, mTexture);
+ // Use GL_COMPRESSED_RGB8_ETC2 which is core in ES 3.0
+ glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_COMPRESSED_RGB8_ETC2, kWidth, kHeight, 0,
+ compressedSize, nullptr);
+
+ // Misaligned offset to trigger the fallback path in Metal backend
+ constexpr GLsizei kPBOOffset = 1;
+
+ GLBuffer pbo;
+ glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo);
+ glBufferData(GL_PIXEL_UNPACK_BUFFER, kPBOOffset + compressedSize, nullptr, GL_STATIC_DRAW);
+ ASSERT_GL_NO_ERROR();
+
+ glCompressedTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kWidth, kHeight, GL_COMPRESSED_RGB8_ETC2,
+ compressedSize, reinterpret_cast<void *>(kPBOOffset));
+ EXPECT_GL_NO_ERROR();
+}
+
class ETCToBCTextureTest : public ANGLETest<>
{
protected: