CVE-2026-11044
Overview
Background
- ANGLE
- Chrome’s graphics abstraction layer that translates OpenGL ES calls onto native backends such as Metal on Apple platforms.
- `readPixels`
- the GL entry point that copies a rectangular region of framebuffer pixels back into client memory, which the Metal backend may service via a texture-to-buffer copy optimization.
- `bufferRowPitch`
- the number of bytes per row of the intermediate buffer, computed as texture width multiplied by
readAngleFormat.pixelBytes. - `Feature::CopyTextureToBufferForReadOptimization`
- the Metal feature flag that enables the code path in
FrameBufferMtl.mmwhere this pitch is computed.
Root Cause Analysis
When Feature::CopyTextureToBufferForReadOptimization is active, readPixelsImpl in FrameBufferMtl.mm copies the source texture slice into a temporary buffer and then computes bufferRowPitch to index into the returned bytes. The original code declared bufferRowPitch as an int and computed it as texture->width(level) * readAngleFormat.pixelBytes, where both operands are 32-bit, so the multiplication was performed in 32-bit signed arithmetic. For a large texture such as 16384x8193 RGBA32F the product 16384 * 16 exceeds INT_MAX, so the value overflowed and wrapped to a wrong (possibly negative) pitch, violating the invariant that bufferRowPitch accurately describes the byte stride of bufferData.
The fix casts texture->width(level) to size_t before the multiply, forcing the whole expression into 64-bit unsigned arithmetic and yielding the correct stride, so subsequent indexing into the mapped buffer stays in bounds.
int before assigning it to a pitch used for buffer indexing; the fix promotes the width to size_t so the product cannot overflow.Attack Path
- Enable the optimized read path
A page renders into a very large RGBA32F texture-backed framebuffer on the Metal backend, reaching the
CopyTextureToBufferForReadOptimizationbranch ofreadPixelsImpl. - Trigger the overflowing multiply
Calling
glReadPixelson a texture wide enough thatwidth * pixelBytesexceedsINT_MAX(e.g.16384x8193 RGBA32F) makes the 32-bitbufferRowPitchwrap to an incorrect value. - Miscompute buffer indexing
Downstream indexing into the mapped
bufferDatauses the corrupted pitch, so reads land at incorrect offsets relative to the intended row.(inferred) - Out-of-bounds buffer access
The wrong stride can drive an out-of-bounds read of the temporary buffer during the readback copy.
(inferred)
Impact Assessment
bufferRowPitch that leads to incorrect buffer indexing during readPixels, with the precondition of the Metal backend, the CopyTextureToBufferForReadOptimization feature, and an allocatable large texture (e.g. 16384x8193 RGBA32F). This yields at most an out-of-bounds buffer read within the GPU process; escalation beyond the incorrect readback shown by the patch is (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5.Changed Functions
| Function | Change | Notes |
|---|---|---|
ReadPixelsPBONVTestsrc/tests/gl_tests/ReadPixelsTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/metal/FrameBufferMtl.mmsrc/tests/angle_end2end_tests_expectations.txtsrc/tests/gl_tests/ReadPixelsTest.cpp
Audit Directions
- 32-bit pitch/stride computationsSearch other ANGLE backends for
int-typed row pitch, stride, or size expressions built fromwidth * pixelBytes(or height/depth products) that should besize_t. - Texture dimension arithmeticAudit any multiplication of user-controllable texture dimensions by per-pixel byte counts before promotion to a wide type, especially in copy and readback paths.
- Feature-gated fast pathsReview optimization branches like
CopyTextureToBufferForReadOptimizationthat are off by default in tests, since their integer arithmetic may be under-exercised and prone to overflow on large inputs.
Patch
From 776d8d2a7146d53cd66998795691a45ea16888ba Mon Sep 17 00:00:00 2001 From: Le Hoang Quyen <[email protected]> Date: Wed, 08 Apr 2026 18:19:21 +0800 Subject: [PATCH] Metal: Fix integer overflow in readPixels When copying texture to buffer for read optimization, the calculated bufferRowPitch was an int, which could overflow for large textures (e.g., 16384x8193 RGBA32F). Cast texture width to size_t before multiplying by pixel bytes to prevent this. Also added a test LargeTexture to ReadPixelsTest to verify this, and enabled the copyTextureToBufferForReadOptimization feature for the ES3 Metal test instantiation. Fixed: angleproject:498724803 Change-Id: I21de2d3f30e13fecd60fa5bc3fbeec101bcb28d9 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7736870 Commit-Queue: Quyen Le <[email protected]> Reviewed-by: Kimmo Kinnunen <[email protected]> Reviewed-by: Quyen Le <[email protected]> --- diff --git a/src/libANGLE/renderer/metal/FrameBufferMtl.mm b/src/libANGLE/renderer/metal/FrameBufferMtl.mm index 9ebc41a..59bf9d4 100644 --- a/src/libANGLE/renderer/metal/FrameBufferMtl.mm +++ b/src/libANGLE/renderer/metal/FrameBufferMtl.mm @@ -1632,8 +1632,8 @@ ANGLE_TRY(CopyTextureSliceLevelToTempBuffer(context, texture, renderTarget->getLevelIndex(), renderTarget->getLayerIndex(), &buffer)); - int bufferRowPitch = - texture->width(renderTarget->getLevelIndex()) * readAngleFormat.pixelBytes; + size_t bufferRowPitch = static_cast<size_t>(texture->width(renderTarget->getLevelIndex())) * + readAngleFormat.pixelBytes; buffer->syncContent(contextMtl, contextMtl->getBlitCommandEncoder()); const uint8_t *bufferData = buffer->mapReadOnly(contextMtl).data(); diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt index 8a35b0d..a916c5f 100644 --- a/src/tests/angle_end2end_tests_expectations.txt +++ b/src/tests/angle_end2end_tests_expectations.txt @@ -2666,6 +2666,11 @@ // Untriaged failures on Pixel 4 / GLES 349994211 IR PIXEL4ORXL GLES : GLSLTestLoops.ForContinueInSwitchComplex/* = SKIP +// Crashes on OpenGL +500930576 OPENGL : ReadPixelsTest.LargeTexture/* = SKIP +// Crashes inside AMD Metal driver when copying the texture -> buffer +500930576 METAL AMD : ReadPixelsTest.LargeTexture/* = SKIP + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // Slow tests, should appear last in this file // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! diff --git a/src/tests/gl_tests/ReadPixelsTest.cpp b/src/tests/gl_tests/ReadPixelsTest.cpp index 3cf1179..a9668d0 100644 --- a/src/tests/gl_tests/ReadPixelsTest.cpp +++ b/src/tests/gl_tests/ReadPixelsTest.cpp @@ -66,6 +66,49 @@ } } +// Test readPixels with a large texture to verify no overflow in pitch calculations. +TEST_P(ReadPixelsTest, LargeTexture) +{ + // Need ES3 for RGBA32F + ANGLE_SKIP_TEST_IF(getClientMajorVersion() < 3); + + GLint maxTextureSize; + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxTextureSize); + ANGLE_SKIP_TEST_IF(maxTextureSize < 16384); + + GLTexture tex; + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, 16384, 8193, 0, GL_RGBA, GL_FLOAT, nullptr); + ANGLE_SKIP_TEST_IF(glGetError() == GL_OUT_OF_MEMORY); + EXPECT_GL_NO_ERROR(); + + GLFramebuffer fbo; + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0); + EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER); + + // Clear the row at 8192 to a specific color. + glEnable(GL_SCISSOR_TEST); + glScissor(0, 8192, 16384, 1); + glClearColor(0.5f, 0.5f, 0.5f, 0.5f); + glClear(GL_COLOR_BUFFER_BIT); + glDisable(GL_SCISSOR_TEST); + + ANGLE_SKIP_TEST_IF(glGetError() == GL_OUT_OF_MEMORY); + EXPECT_GL_NO_ERROR(); + + // Call gl.readPixels(0, 8192, 16384, 1, gl.RGBA, gl.FLOAT, outputBuffer). + std::vector<float> data(16384 * 4); + glReadPixels(0, 8192, 16384, 1, GL_RGBA, GL_FLOAT, data.data()); + ANGLE_SKIP_TEST_IF(glGetError() == GL_OUT_OF_MEMORY); + EXPECT_GL_NO_ERROR(); + + for (size_t i = 0; i < 16384 * 4; ++i) + { + EXPECT_EQ(0.5f, data[i]); + } +} + class ReadPixelsPBONVTest : public ReadPixelsTest { protected: @@ -1701,7 +1744,9 @@ // Use this to select which configurations (e.g. which renderer, which GLES major version) these // tests should be run against. -ANGLE_INSTANTIATE_TEST_ES2(ReadPixelsTest); +ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND( + ReadPixelsTest, + ES3_METAL().enable(Feature::CopyTextureToBufferForReadOptimization)); ANGLE_INSTANTIATE_TEST_ES2(ReadPixelsPBONVTest); GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(ReadPixelsPBOTest);
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 8a35b0d..a916c5f 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2666,6 +2666,11 @@
// Untriaged failures on Pixel 4 / GLES
349994211 IR PIXEL4ORXL GLES : GLSLTestLoops.ForContinueInSwitchComplex/* = SKIP
+// Crashes on OpenGL
+500930576 OPENGL : ReadPixelsTest.LargeTexture/* = SKIP
+// Crashes inside AMD Metal driver when copying the texture -> buffer
+500930576 METAL AMD : ReadPixelsTest.LargeTexture/* = SKIP
+
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// Slow tests, should appear last in this file
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
diff --git a/src/tests/gl_tests/ReadPixelsTest.cpp b/src/tests/gl_tests/ReadPixelsTest.cpp
index 3cf1179..a9668d0 100644
--- a/src/tests/gl_tests/ReadPixelsTest.cpp
+++ b/src/tests/gl_tests/ReadPixelsTest.cpp
@@ -66,6 +66,49 @@
}
}
+// Test readPixels with a large texture to verify no overflow in pitch calculations.
+TEST_P(ReadPixelsTest, LargeTexture)
+{
+ // Need ES3 for RGBA32F
+ ANGLE_SKIP_TEST_IF(getClientMajorVersion() < 3);
+
+ GLint maxTextureSize;
+ glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxTextureSize);
+ ANGLE_SKIP_TEST_IF(maxTextureSize < 16384);
+
+ GLTexture tex;
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, 16384, 8193, 0, GL_RGBA, GL_FLOAT, nullptr);
+ ANGLE_SKIP_TEST_IF(glGetError() == GL_OUT_OF_MEMORY);
+ EXPECT_GL_NO_ERROR();
+
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
+ EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ // Clear the row at 8192 to a specific color.
+ glEnable(GL_SCISSOR_TEST);
+ glScissor(0, 8192, 16384, 1);
+ glClearColor(0.5f, 0.5f, 0.5f, 0.5f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glDisable(GL_SCISSOR_TEST);
+
+ ANGLE_SKIP_TEST_IF(glGetError() == GL_OUT_OF_MEMORY);
+ EXPECT_GL_NO_ERROR();
+
+ // Call gl.readPixels(0, 8192, 16384, 1, gl.RGBA, gl.FLOAT, outputBuffer).
+ std::vector<float> data(16384 * 4);
+ glReadPixels(0, 8192, 16384, 1, GL_RGBA, GL_FLOAT, data.data());
+ ANGLE_SKIP_TEST_IF(glGetError() == GL_OUT_OF_MEMORY);
+ EXPECT_GL_NO_ERROR();
+
+ for (size_t i = 0; i < 16384 * 4; ++i)
+ {
+ EXPECT_EQ(0.5f, data[i]);
+ }
+}
+
class ReadPixelsPBONVTest : public ReadPixelsTest
{
protected:
@@ -1701,7 +1744,9 @@
// Use this to select which configurations (e.g. which renderer, which GLES major version) these
// tests should be run against.
-ANGLE_INSTANTIATE_TEST_ES2(ReadPixelsTest);
+ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND(
+ ReadPixelsTest,
+ ES3_METAL().enable(Feature::CopyTextureToBufferForReadOptimization));
ANGLE_INSTANTIATE_TEST_ES2(ReadPixelsPBONVTest);
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(ReadPixelsPBOTest);
Original Bug Report
Potential integer overflow in ANGLE Metal glReadPixels leads to OOB read
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.
Overview: A signed integer overflow in ANGLE’s Metal backend optimization for glReadPixels can cause a massive out-of-bounds read in the GPU process. By allocating a large WebGL texture and reading from a high Y-offset, an attacker can cause row offset pointer arithmetic to wrap to a negative value. This potentially allows exfiltration of sensitive GPU memory to the sandboxed renderer process.
Affected files:
third_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mm
Estimated timestamp from git blame: 2022-04-26
Vulnerability Details
In ANGLE’s Metal backend, the copyTextureToBufferForReadOptimization feature is enabled by default on certain hardware configurations, such as AMD GPUs on macOS. When this optimization is active, FramebufferMtl::readPixelsImpl copies the entire texture into a temporary Metal buffer, maps it into CPU memory, and then copies the requested region row-by-row into the destination buffer.
During this row-by-row copy, a lambda function calculates the source memory address for the read:
// third_party/angle/src/libANGLE/renderer/metal/FrameBufferMtl.mm
int bufferRowPitch =
texture->width(renderTarget->getLevelIndex()) * readAngleFormat.pixelBytes;
// ...
angle::Result result = readPixelsCopyImpl(
context, area, packPixelsParams, renderTarget,
[&](const gl::Rectangle ®ion, const uint8_t *&src) {
src =
bufferData + region.y * bufferRowPitch + region.x * readAngleFormat.pixelBytes;
return angle::Result::Continue;
},
pixels);
Both region.y (from gl::Rectangle’s internal RectangleImpl<int>) and bufferRowPitch are 32-bit signed integers (int).
If an attacker creates a texture with maximum dimensions (e.g., width 16384) and a 16-byte-per-pixel format (like RGBA32F), bufferRowPitch evaluates to 262,144. If glReadPixels is called with a Y-offset of 8192, the multiplication region.y * bufferRowPitch equals 2,147,483,648.
This exceeds INT_MAX (2,147,483,647). Because Chromium is compiled with -fno-strict-overflow, the undefined behavior of signed integer overflow wraps around to a negative value (-2,147,483,648).
Due to C++ operator precedence and left-to-right associativity, the 64-bit pointer bufferData is added to this negative 32-bit integer, which gets sign-extended. This shifts the pointer backward by 2GB, causing PackPixels to read massively out-of-bounds GPU process memory. This data is then returned to the WebGL context in the attacker’s ArrayBuffer.
Potential Steps to Reproduce
Note: These are suggested steps. As an AI tooling agent, I do not have the ability to run code or execute a live proof-of-concept.
- On a macOS system with an AMD GPU, host a webpage with a WebGL 2.0 context.
- Query
MAX_TEXTURE_SIZEto confirm a limit of at least 16384. - Create a large 2D texture:
gl.texImage2D(..., gl.RGBA32F, 16384, 8193, ...), which safely allocates a ~2.14GB texture. - Bind the texture to a Framebuffer Object.
- Call
gl.readPixels(0, 8192, 16384, 1, gl.RGBA, gl.FLOAT, outputBuffer). - The offset calculation
8192 * (16384 * 16)wraps to a negative value, shifting the read pointer backwards by 2GB. - Inspect
outputBufferin JavaScript to read out-of-bounds GPU memory, potentially leaking cross-origin data or ASLR pointers.
Proposed Fix
To prevent the overflow, cast the region offsets to size_t before multiplication, or simply declare bufferRowPitch as size_t (which forces the promotion of the multiplication).
size_t bufferRowPitch =
static_cast<size_t>(texture->width(renderTarget->getLevelIndex())) * readAngleFormat.pixelBytes;
// ...
src =
bufferData + static_cast<size_t>(region.y) * bufferRowPitch + static_cast<size_t>(region.x) * readAngleFormat.pixelBytes;
Evaluated with Chrome root at commit: e9e0fcbb690b1a8c1a26c81c2a9ea23d6e178368
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.