Medium chrome Integer Overflow 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in ANGLE
DescriptionInteger overflow in ANGLE
ComponentANGLE
Bug ClassInteger Overflow
Tracker498724803
Fix commit776d8d2a7146 (angle/angle) +53/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
ReadPixelsPBONVTest
src/tests/gl_tests/ReadPixelsTest.cpp
modified

Files Changed

  • src/libANGLE/renderer/metal/FrameBufferMtl.mm
  • src/tests/angle_end2end_tests_expectations.txt
  • src/tests/gl_tests/ReadPixelsTest.cpp
From 776d8d2a7146d53cd66998795691a45ea16888ba Mon Sep 17 00:00:00 2001
From: Le Hoang Quyen <lehoangquyen@google.com>
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 <lehoangquyen@chromium.org>
Reviewed-by: Kimmo Kinnunen <kkinnunen@apple.com>
Reviewed-by: Quyen Le <lehoangquyen@chromium.org>
---

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);
Loading diff…

Regression Test / PoC

shipped with the fix
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);
Loading diff…

Original Bug Report

reported by rj...@google.com

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 &region, 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.

  1. On a macOS system with an AMD GPU, host a webpage with a WebGL 2.0 context.
  2. Query MAX_TEXTURE_SIZE to confirm a limit of at least 16384.
  3. Create a large 2D texture: gl.texImage2D(..., gl.RGBA32F, 16384, 8193, ...), which safely allocates a ~2.14GB texture.
  4. Bind the texture to a Framebuffer Object.
  5. Call gl.readPixels(0, 8192, 16384, 1, gl.RGBA, gl.FLOAT, outputBuffer).
  6. The offset calculation 8192 * (16384 * 16) wraps to a negative value, shifting the read pointer backwards by 2GB.
  7. Inspect outputBuffer in 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.

View on issue tracker