Released May 11, 2026

CVE-2026-28913

CVSS 7.5 severity high CWE-119 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected process crash

Researchers: an anonymous researcher

Key insight. ANGLE’s Metal sub-image upload used pixelBytes*width as the row pitch for block-compressed formats, badly under-sizing it and copying out of bounds; computing the pitch with block-aware helpers fixes it.

Patch Details

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:

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-28913
Bugzilla 311631
Component WebKit
Bug Class OOB
Severity medium
Commit a9f9b9ecfd4bcdaf…
Advisory Apple Advisory

Terminology

  • 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 pixelByteswidth 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 pixelByteswidth 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

1. Use a compressed texture in WebGL

On a Metal/ANGLE backend, upload a block-compressed texture (e.g. ETC2) via a sub-image update.

2. Hit the per-slice convert path

convertAndSetPerSliceSubImage computes dstRowPitch as pixelBytes*width, far smaller than the compressed layout needs.

3. Under-sized pitch

The copy uses the wrong pitch to index the destination, walking outside the staging buffer.

4. Out-of-bounds / crash

The mis-pitched copy reads/writes out of bounds in the GPU/WebContent process.

Impact Assessment

An out-of-bounds access from a mis-computed destination pitch when uploading block-compressed textures, reachable from WebGL compressed-texture sub-image uploads in the GPU/WebContent process. The observable is a crash; controllability depends on the format/size, and the change is a single isolable ANGLE file (unlike a bulk roll). Confined to the GPU/WebContent process; rated medium.

Audit Directions

Other pixelBytes*width pitches

grep ANGLE metal/ for rowPitch/depthPitch computed as pixelBytes*width without an isBlock branch.

Compressed-format paths

Audit texture upload/readback/copy code for compressed formats using per-pixel pitch math.

Overflow checks

Ensure compressed pitch computations use ANGLE_CHECK_GL_MATH so large dimensions can’t overflow the pitch.

Changed Functions

Function File Change Note
TextureMtl::convertAndSetPerSliceSubImage Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm modified Computes dstRowPitch/dstDepthPitch via computeCompressedImageRowPitch/DepthPitch (ANGLE_CHECK_GL_MATH) for block-compressed formats instead of pixelBytes*width, fixing the under-sized pitch.

Files Changed

Source Files

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

Test Files

  • Source/ThirdParty/ANGLE/src/tests/gl_tests/ETCTextureTest.cpp