CVE-2026-9122
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ETCToBCTextureTestsrc/tests/gl_tests/ETCTextureTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/metal/TextureMtl.mmsrc/tests/gl_tests/ETCTextureTest.cpp
Patch
From 0323970550b9e2b52d46b3e7bb3c776bbb3c5c91 Mon Sep 17 00:00:00 2001
From: Kenneth Russell <kbr@chromium.org>
Date: Mon, 30 Mar 2026 18:13:36 -0700
Subject: [PATCH] Metal: Fix pitch computation for compressed textures in PBOs.
Correctly detect block formats and adjust the row and depth pitch
computation. Inspiration taken from ANGLE's Vulkan backend.
Authored with gemini-cli, with guidance from domain experts.
Fixed: angleproject:489579953
Change-Id: I5656b02bc235bbe068191a0fb2049afcea9a094e
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7699417
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Kenneth Russell <kbr@chromium.org>
---
diff --git a/src/libANGLE/renderer/metal/TextureMtl.mm b/src/libANGLE/renderer/metal/TextureMtl.mm
index a671ff7..6f75fde 100644
--- a/src/libANGLE/renderer/metal/TextureMtl.mm
+++ b/src/libANGLE/renderer/metal/TextureMtl.mm
@@ -2470,7 +2470,29 @@
? 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.
@@ -2484,7 +2506,6 @@
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));
@@ -2508,7 +2529,6 @@
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/src/tests/gl_tests/ETCTextureTest.cpp b/src/tests/gl_tests/ETCTextureTest.cpp
index 319eb1c..0d2f732 100644
--- a/src/tests/gl_tests/ETCTextureTest.cpp
+++ b/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 @@
}
}
+// 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/src/tests/gl_tests/ETCTextureTest.cpp b/src/tests/gl_tests/ETCTextureTest.cpp
index 319eb1c..0d2f732 100644
--- a/src/tests/gl_tests/ETCTextureTest.cpp
+++ b/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 @@
}
}
+// 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:
Original Bug Report
OOB Read in ANGLE Metal Backend During Block-Compressed PBO Texture Upload Crashes GPU Process on Mac
OOB Read in ANGLE Metal Backend During Block-Compressed PBO Texture Upload Crashes GPU Process on Mac
Summary
The ANGLE Metal backend miscomputes the destination row pitch when uploading block-compressed texture data through a Pixel Buffer Object with a non-block-aligned offset. The function convertAndSetPerSliceSubImage in TextureMtl.mm calculates dstRowPitch as pixelBytes * width, treating pixelBytes as a per-pixel value, but for block-compressed formats this field holds the byte size of an entire compressed block. The resulting row pitch is inflated by a factor of the block width (typically 4x), and when passed to Metal’s replaceRegion, it causes the GPU process to read far past the end of the source buffer. On macOS with Apple Silicon and ETC2 textures, a 512x512 upload produces a ~384 KB out-of-bounds read that crosses page boundaries and kills the GPU process with SIGSEGV. This is reachable from a sandboxed renderer via a plain WebGL2 page with no special flags or permissions.
Platform: macOS (Apple Silicon with native ETC2/ASTC support, macOS 11.0 or later).
Bisect
Introducing Commit: d33a22228ee2999ab5e2d2eda4d405c5768555d2
- Date: Mon Apr 26 16:56:15 2021 -0700
- Author: Kyle Piddington <kpiddington@apple.com>
- Review: https://chromium-review.googlesource.com/c/angle/angle/+/2950067
Root Cause
When a WebGL2 page calls compressedTexSubImage2D with a bound PIXEL_UNPACK_BUFFER, the data offset encoded in the pixels parameter reaches TextureMtl::setPerSliceSubImage. If that offset is not evenly divisible by the format’s pixelBytes, the function falls through to convertAndSetPerSliceSubImage:
// TextureMtl.mm — setPerSliceSubImage
uintptr_t offset = reinterpret_cast<uintptr_t>(pixels);
if (offset % imageFormat.actualAngleFormat().pixelBytes || pixelsRowPitch < minRowPitch)
{
return convertAndSetPerSliceSubImage(/* ... */, unpackBuffer, pixels, imageDef);
}
ANGLE’s ES3 validation intentionally skips the PBO offset alignment check for compressed formats, so an offset of 1 passes validation and always satisfies the modulus condition above for any format with pixelBytes > 1.
Inside convertAndSetPerSliceSubImage, when an unpack buffer is present and the format is block-compressed, the function obtains a CPU pointer to the buffer’s shadow copy and recurses with unpackBuffer set to null:
// TextureMtl.mm — convertAndSetPerSliceSubImage, unpackBuffer branch
if (imageFormat.intendedAngleFormat().isBlock || /* ... */)
{
const uint8_t *clientData = unpackBufferMtl->getBufferDataReadOnly(contextMtl);
clientData += offset;
ANGLE_TRY(convertAndSetPerSliceSubImage(
context, slice, mtlArea, internalFormat, type, pixelsAngleFormat,
pixelsRowPitch, pixelsDepthPitch, nullptr, clientData, imageDef));
}
The recursive call enters the unpackBuffer == nullptr branch, which computes the destination row pitch and dispatches based on whether the compressed format is natively supported:
// TextureMtl.mm — convertAndSetPerSliceSubImage, else (no unpackBuffer)
const angle::Format &dstFormat = angle::Format::Get(imageFormat.actualFormatId);
const size_t dstRowPitch = dstFormat.pixelBytes * mtlArea.size.width;
For uncompressed formats, pixelBytes is the number of bytes per pixel, so multiplying by the pixel width gives the correct row pitch. For block-compressed formats, however, pixelBytes holds the number of bytes per compressed block (8 for ETC2_RGB8, 16 for ASTC 4x4), while mtlArea.size.width is still the width in pixels. The product is therefore blockWidth times too large. For a 512x512 ETC2_RGB8 texture with 4x4 blocks, the correct row pitch is 128 blocks times 8 bytes = 1024, but the code computes 8 times 512 = 4096.
When the intended and actual format IDs match, meaning Metal supports the compressed format natively, the code passes the inflated pitch directly to UploadTextureContents with staging buffers explicitly disabled:
// TextureMtl.mm — convertAndSetPerSliceSubImage, isBlock + native format
if (imageFormat.intendedFormatId == imageFormat.actualFormatId)
{
const size_t dstDepthPitch = dstRowPitch * mtlArea.size.height;
ANGLE_TRY(UploadTextureContents(
context, dstFormat, mtlArea, mtl::kZeroNativeMipLevel, slice,
pixels, dstRowPitch, dstDepthPitch, /*avoidStagingBuffers=*/true, imageDef.image));
}
UploadTextureContents finds that the texture is CPU-accessible (macOS managed storage) and calls Metal’s replaceRegion with the wrong bytesPerRow:
// TextureMtl.mm — UploadTextureContents
if (texture->isCPUAccessible() && !preferGPUInitialization)
{
texture->replaceRegion(contextMtl, region, mipmapLevel, slice,
data, bytesPerRow, bytesPer2DImage);
}
Metal interprets bytesPerRow as the stride between consecutive rows of blocks. With the inflated value of 4096 instead of 1024, the second block row is read from offset 4096 in the source buffer, the third from 8192, and so on. The source buffer is only 131072 bytes (the correct compressed data size), but the last block row is accessed at offset 4096 times 127 = 520192. The resulting ~384 KB out-of-bounds read crosses page boundaries and triggers SIGSEGV in the GPU process.
On macOS 11.0 and later, ETC2 maps to its native Metal pixel format (MTLPixelFormatETC2_RGB8), satisfying the intendedFormatId == actualFormatId condition. The same applies to ASTC formats on all macOS versions. Any block-compressed format that Metal supports natively is affected.
Reproduce
This PoC reproduces a GPU process crash caused by a row pitch miscalculation in the ANGLE Metal backend when uploading block-compressed textures via PBO with a misaligned offset. It was tested on Chromium commit d0f83d769eeed (with ANGLE at 8dc22feb4412) on macOS (Apple Silicon). The bug requires a Mac with native ETC2 support, which includes all Apple Silicon machines running macOS 11.0 or later.
To check out the tested revision, run git checkout d0f83d769eeed in ~/chromium/src. No source modifications or patches are needed.
Configure an ASAN build by writing the following to ~/chromium/src/out/asan-release/args.gn:
is_asan = true
is_debug = false
is_component_build = true
symbol_level = 1
dcheck_always_on = false
Then build with autoninja -C ~/chromium/src/out/asan-release chrome.
Launch Chrome and open the PoC:
ASAN_OPTIONS=detect_odr_violation=0 ~/chromium/src/out/asan-release/Chromium.app/Contents/MacOS/Chromium --user-data-dir=/tmp/poc-test --enable-logging=stderr poc.html
Within a few seconds the GPU process will receive signal 11 (SEGV_ACCERR) and crash. The browser process logs “GPU process exited unexpectedly: exit_code=11” and “The GPU process has crashed 1 time(s)” to stderr. The crash is caused by Metal’s replaceRegion reading past the end of a heap buffer due to the inflated bytesPerRow value computed by ANGLE. Because the out-of-bounds read occurs inside Apple’s Metal framework rather than in ASAN-instrumented code, the crash manifests as a raw SIGSEGV rather than a formal ASAN report.
Crash log:
Received signal 11 SEGV_ACCERR 000344460001
[0x000107360d88]
[0x000107316580]
[0x00010736093c]
[0x00019bfd56a4]
[0x00030a2195a4]
[0x00030a2195a4]
[0x00030a219530]
[0x0003079e6640]
[0x00030796774c]
[0x00030796bab8]
[0x00030796b98c]
[0x00030796ac2c]
[0x0003079645b0]
[0x000307964b10]
[0x0003075cb44c]
[0x00030743ac1c]
[0x00030705b344]
[0x0001482119fc]
[0x0001482591f0]
[0x0001481e16d0]
[0x00013f53ab48]
[0x00014397e170]
[0x00014397d2f0]
[0x0001439a1a00]
[0x0001439adaac]
[0x0001439ad8c4]
[0x00013f581b00]
[0x00013f556010]
[0x00013f5546a8]
[0x00013f558850]
[0x000107189808]
[0x000107206e88]
[0x000107206240]
[0x0001073923d0]
[0x00010737cdfc]
[0x000107390760]
[0x00019c086b14]
[0x00019c086aa8]
[0x00019c086814]
[0x00019c085468]
[0x00019c084a98]
[0x00019d654c78]
[0x000107393dbc]
[0x00010738f338]
[0x000107208244]
[0x0001070f4b0c]
[0x0001356e8a08]
[0x000139460acc]
[0x000139462c4c]
[0x00013945e55c]
[0x00013945ea4c]
[0x00011d217728]
[0x000104becb98]
[0x00019bbfab98]
[end of stack trace]
[1151:68081751:0304/141207.415969:ERROR:gpu/ipc/client/command_buffer_proxy_impl.cc:484] GPU state invalid after WaitForGetOffsetInRange.
[1123:68081282:0304/141207.458497:ERROR:content/browser/gpu/gpu_process_host.cc:999] GPU process exited unexpectedly: exit_code=11
[1123:68081282:0304/141207.458534:WARNING:content/browser/gpu/gpu_process_host.cc:1441] The GPU process has crashed 1 time(s)
Credit
Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.