Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in ANGLE
DescriptionInteger overflow in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassInteger Overflow
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker498724803
Fix commit776d8d2a7146 (angle/angle) +53/-3
CWECWE-472, CWE-190 (Integer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-01 (per vendor advisory)
Fix landed2026-04-10 (public git)
Disclosed2026-06-02
Patch gap53 days — fix public in source before the advisory

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.mm where 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.

Key insight
The single mistake is performing a width-times-pixel-bytes multiplication in 32-bit 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

  1. Enable the optimized read path A page renders into a very large RGBA32F texture-backed framebuffer on the Metal backend, reaching the CopyTextureToBufferForReadOptimization branch of readPixelsImpl.
  2. Trigger the overflowing multiply Calling glReadPixels on a texture wide enough that width * pixelBytes exceeds INT_MAX (e.g. 16384x8193 RGBA32F) makes the 32-bit bufferRowPitch wrap to an incorrect value.
  3. Miscompute buffer indexing Downstream indexing into the mapped bufferData uses the corrupted pitch, so reads land at incorrect offsets relative to the intended row. (inferred)
  4. 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

An attacker controlling a WebGL/GPU-process context can trigger an integer overflow in 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

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

Audit Directions

  • 32-bit pitch/stride computations
    Search other ANGLE backends for int-typed row pitch, stride, or size expressions built from width * pixelBytes (or height/depth products) that should be size_t.
  • Texture dimension arithmetic
    Audit 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 paths
    Review optimization branches like CopyTextureToBufferForReadOptimization that are off by default in tests, since their integer arithmetic may be under-exercised and prone to overflow on large inputs.
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);
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 [email protected]

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