CVE-2026-28913
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