Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in ANGLE
DescriptionHeap buffer overflow in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker490170083
Fix commitb149a5c62d76 (angle/angle) +53/-8
CISA KEVNot listed
Creditedcinzinga
Disclosed2026-04-15

Files Changed

  • src/libANGLE/renderer/gl/BlitGL.cpp
  • src/tests/capture_replay_tests/capture_replay_expectations.txt
  • src/tests/gl_tests/CopyTextureTest.cpp
From b149a5c62d76ab536929afc5bba2b2774da46102 Mon Sep 17 00:00:00 2001
From: Geoff Lang <geofflang@chromium.org>
Date: Fri, 27 Mar 2026 16:13:31 -0400
Subject: [PATCH] GL: Fix pack state for BlitGL::copySubTextureCPUReadback

copySubTextureCPUReadback does both ReadPixels and TexImage calls and
needs to make sure the client's pack states are not used. It does this
but in the wrong order causing an invalid pack state to be used for the
ReadPixels call.

Bug: chromium:490170083
Change-Id: I93dcabf52edd6e4e08f999aaa0d96d1fc325211a
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7708753
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Geoff Lang <geofflang@chromium.org>
---

diff --git a/src/libANGLE/renderer/gl/BlitGL.cpp b/src/libANGLE/renderer/gl/BlitGL.cpp
index b156d2d..154be25 100644
--- a/src/libANGLE/renderer/gl/BlitGL.cpp
+++ b/src/libANGLE/renderer/gl/BlitGL.cpp
@@ -859,10 +859,10 @@
         readFunction     = angle::ReadColor<angle::R8G8B8A8, GLfloat>;
     }
 
-    gl::PixelUnpackState unpack;
-    unpack.alignment = 1;
-    ANGLE_TRY(mStateManager->setPixelUnpackState(context, unpack));
-    ANGLE_TRY(mStateManager->setPixelUnpackBuffer(context, nullptr));
+    gl::PixelPackState pack;
+    pack.alignment = 1;
+    ANGLE_TRY(mStateManager->setPixelPackState(context, pack));
+    ANGLE_TRY(mStateManager->setPixelPackBuffer(context, nullptr));
     ANGLE_GL_TRY(context, mFunctions->readPixels(readPixelsArea.x, readPixelsArea.y,
                                                  readPixelsArea.width, readPixelsArea.height,
                                                  readPixelsFormat, GL_UNSIGNED_BYTE, sourceMemory));
@@ -877,10 +877,10 @@
         destInternalFormatInfo.format, destInternalFormatInfo.componentType, readPixelsArea.width,
         readPixelsArea.height, 1, unpackFlipY, unpackPremultiplyAlpha, unpackUnmultiplyAlpha);
 
-    gl::PixelPackState pack;
-    pack.alignment = 1;
-    ANGLE_TRY(mStateManager->setPixelPackState(context, pack));
-    ANGLE_TRY(mStateManager->setPixelPackBuffer(context, nullptr));
+    gl::PixelUnpackState unpack;
+    unpack.alignment = 1;
+    ANGLE_TRY(mStateManager->setPixelUnpackState(context, unpack));
+    ANGLE_TRY(mStateManager->setPixelUnpackBuffer(context, nullptr));
 
     nativegl::TexSubImageFormat texSubImageFormat =
         nativegl::GetTexSubImageFormat(mFunctions, mFeatures, destFormat, destType);
diff --git a/src/tests/capture_replay_tests/capture_replay_expectations.txt b/src/tests/capture_replay_tests/capture_replay_expectations.txt
index 74e572d..201c64a 100644
--- a/src/tests/capture_replay_tests/capture_replay_expectations.txt
+++ b/src/tests/capture_replay_tests/capture_replay_expectations.txt
@@ -80,6 +80,7 @@
 42264831 : FramebufferTest_ES3.AttachmentsWithUnequalDimensions/* = SKIP_FOR_CAPTURE
 42264831 : FramebufferTest_ES3.ChangeAttachmentThenInvalidateAndDraw/* = SKIP_FOR_CAPTURE
 42264831 : FramebufferTest_ES3.RenderAndInvalidateImmutableTextureWithBeyondMaxLevel/* = SKIP_FOR_CAPTURE
+490170083 : CopyTextureTestES3.SRGBWithPackParameters/* = SKIP_FOR_CAPTURE
 
 # The following tests fail with forceRobustResourceInit
 # They were accidentally passing until http://crrev/c/5588816
diff --git a/src/tests/gl_tests/CopyTextureTest.cpp b/src/tests/gl_tests/CopyTextureTest.cpp
index cd9bc97..44effc4 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -1988,6 +1988,50 @@
     EXPECT_PIXEL_COLOR_EQ(0, 0, expectedPixels);
 }
 
+// Regression test for TextureGL doing CPU readback when a PBO is bound
+TEST_P(CopyTextureTestES3, SRGBWithPackParameters)
+{
+    ANGLE_SKIP_TEST_IF(!checkExtensions());
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_EXT_sRGB"));
+
+    GLColor originalPixels(50u, 100u, 150u, 155u);
+
+    glBindTexture(GL_TEXTURE_2D, mTextures[1]);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, &originalPixels);
+    EXPECT_GL_NO_ERROR();
+
+    glBindTexture(GL_TEXTURE_2D, mTextures[0]);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB_ALPHA_EXT, 1, 1, 0, GL_SRGB_ALPHA_EXT, GL_UNSIGNED_BYTE,
+                 nullptr);
+    EXPECT_GL_NO_ERROR();
+
+    GLFramebuffer dstFBO;
+    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, dstFBO);
+    glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mTextures[0],
+                           0);
+
+    // Should have no effect on the copy
+    glPixelStorei(GL_PACK_SKIP_PIXELS, 100);
+    glPixelStorei(GL_PACK_SKIP_ROWS, 100);
+    glPixelStorei(GL_PACK_ROW_LENGTH, 100);
+    glPixelStorei(GL_PACK_ALIGNMENT, 8);
+    EXPECT_GL_NO_ERROR();
+
+    std::array<uint8_t, 100 * 100 * 4 * 2> bigPackBuffer = {0};
+    glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, bigPackBuffer.data());
+
+    glCopySubTextureCHROMIUM(mTextures[1], 0, GL_TEXTURE_2D, mTextures[0], 0, 0, 0, 0, 0, 1, 1,
+                             false, false, false);
+    EXPECT_GL_NO_ERROR();
+
+    glPixelStorei(GL_PACK_SKIP_PIXELS, 0);
+    glPixelStorei(GL_PACK_SKIP_ROWS, 0);
+    glPixelStorei(GL_PACK_ROW_LENGTH, 0);
+    glPixelStorei(GL_PACK_ALIGNMENT, 1);
+
+    EXPECT_PIXEL_COLOR_EQ(0, 0, originalPixels);
+}
+
 // Bug where TEXTURE_SWIZZLE_RGBA was not reset after the Luminance workaround. (crbug.com/1022080)
 TEST_P(CopyTextureTestES3, LuminanceWorkaroundTextureSwizzleBug)
 {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/capture_replay_tests/capture_replay_expectations.txt b/src/tests/capture_replay_tests/capture_replay_expectations.txt
index 74e572d..201c64a 100644
--- a/src/tests/capture_replay_tests/capture_replay_expectations.txt
+++ b/src/tests/capture_replay_tests/capture_replay_expectations.txt
@@ -80,6 +80,7 @@
 42264831 : FramebufferTest_ES3.AttachmentsWithUnequalDimensions/* = SKIP_FOR_CAPTURE
 42264831 : FramebufferTest_ES3.ChangeAttachmentThenInvalidateAndDraw/* = SKIP_FOR_CAPTURE
 42264831 : FramebufferTest_ES3.RenderAndInvalidateImmutableTextureWithBeyondMaxLevel/* = SKIP_FOR_CAPTURE
+490170083 : CopyTextureTestES3.SRGBWithPackParameters/* = SKIP_FOR_CAPTURE
 
 # The following tests fail with forceRobustResourceInit
 # They were accidentally passing until http://crrev/c/5588816
diff --git a/src/tests/gl_tests/CopyTextureTest.cpp b/src/tests/gl_tests/CopyTextureTest.cpp
index cd9bc97..44effc4 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -1988,6 +1988,50 @@
     EXPECT_PIXEL_COLOR_EQ(0, 0, expectedPixels);
 }
 
+// Regression test for TextureGL doing CPU readback when a PBO is bound
+TEST_P(CopyTextureTestES3, SRGBWithPackParameters)
+{
+    ANGLE_SKIP_TEST_IF(!checkExtensions());
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_EXT_sRGB"));
+
+    GLColor originalPixels(50u, 100u, 150u, 155u);
+
+    glBindTexture(GL_TEXTURE_2D, mTextures[1]);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, &originalPixels);
+    EXPECT_GL_NO_ERROR();
+
+    glBindTexture(GL_TEXTURE_2D, mTextures[0]);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB_ALPHA_EXT, 1, 1, 0, GL_SRGB_ALPHA_EXT, GL_UNSIGNED_BYTE,
+                 nullptr);
+    EXPECT_GL_NO_ERROR();
+
+    GLFramebuffer dstFBO;
+    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, dstFBO);
+    glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mTextures[0],
+                           0);
+
+    // Should have no effect on the copy
+    glPixelStorei(GL_PACK_SKIP_PIXELS, 100);
+    glPixelStorei(GL_PACK_SKIP_ROWS, 100);
+    glPixelStorei(GL_PACK_ROW_LENGTH, 100);
+    glPixelStorei(GL_PACK_ALIGNMENT, 8);
+    EXPECT_GL_NO_ERROR();
+
+    std::array<uint8_t, 100 * 100 * 4 * 2> bigPackBuffer = {0};
+    glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, bigPackBuffer.data());
+
+    glCopySubTextureCHROMIUM(mTextures[1], 0, GL_TEXTURE_2D, mTextures[0], 0, 0, 0, 0, 0, 1, 1,
+                             false, false, false);
+    EXPECT_GL_NO_ERROR();
+
+    glPixelStorei(GL_PACK_SKIP_PIXELS, 0);
+    glPixelStorei(GL_PACK_SKIP_ROWS, 0);
+    glPixelStorei(GL_PACK_ROW_LENGTH, 0);
+    glPixelStorei(GL_PACK_ALIGNMENT, 1);
+
+    EXPECT_PIXEL_COLOR_EQ(0, 0, originalPixels);
+}
+
 // Bug where TEXTURE_SWIZZLE_RGBA was not reset after the Luminance workaround. (crbug.com/1022080)
 TEST_P(CopyTextureTestES3, LuminanceWorkaroundTextureSwizzleBug)
 {
Loading diff…

Original Bug Report

reported by ci...@gmail.com

ANGLE BlitGL copySubTextureCPUReadback Controlled Heap Overflow via Unsynchronized PACK_ROW_LENGTH


Report description

ANGLE BlitGL copySubTextureCPUReadback Controlled Heap Overflow via Unsynchronized PACK_ROW_LENGTH


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp


The problem

Please describe the technical details of the vulnerability

kTexImageDirtyBits in ANGLE’s Context.cpp omits DIRTY_BIT_PACK_STATE and DIRTY_BIT_PACK_BUFFER_BINDING. When copySubTextureCPUReadback (BlitGL.cpp) calls glReadPixels on a scratch heap buffer, it inherits stale GL_PACK_ROW_LENGTH from native GL, causing a controlled heap overflow. Triggered from JavaScript via WebGL2, no compromised renderer required.

Data written is attacker-controlled via source WebGL canvas pixel data (RGBA bytes rendered by attacker). Write offset is attacker-controlled via PACK_ROW_LENGTH which sets row stride. Write size is width * 4 bytes per row, tunable via source canvas dimensions.

Tight primitive: width=2, height=2, PACK_ROW_LENGTH=N gives exactly 8 controlled bytes at offset N*4 past the scratch buffer.

Attack Chain:

  1. Set PACK_ROW_LENGTH=N. Forwarded to ANGLE via command buffer (gles2_implementation.cc uses break, not return, for this param).
  2. Bind PBO, then readPixels. This bypasses ScopedPackStateRowLengthReset (disabled when PBO bound, passthrough_doers.cc:2661). ANGLE syncs PACK_ROW_LENGTH=N to native GL.
  3. Unbind and delete PBO. StateManagerGL::deleteBuffer unbinds PBO in native GL. PACK_ROW_LENGTH persists.
  4. Call texImage2D(SRGB8_ALPHA8, webglCanvas). SRGB dest forces CPU readback path (TextureGL.cpp:1113,1133 skip fast paths when destSRGB=true). WebGL canvas source is texture-backed, routes through CopySubTextureCHROMIUM to copySubTextureCPUReadback.
  5. Overflow. BlitGL.cpp:859 calls native glReadPixels with stale PACK_ROW_LENGTH=N, stride N*4 bytes per row into a width*height*4*2 byte scratch buffer.

Affected Code

// third_party/angle/src/libANGLE/Context.cpp:86-89
constexpr state::DirtyBits kTexImageDirtyBits{
    state::DIRTY_BIT_UNPACK_STATE,
    state::DIRTY_BIT_UNPACK_BUFFER_BINDING,
    // Missing: DIRTY_BIT_PACK_STATE, DIRTY_BIT_PACK_BUFFER_BINDING
};

syncStateForTexImage() uses kTexImageDirtyBits. It syncs unpack state but never pack state. Compare with kReadPixelsDirtyBits (line 94) which correctly includes DIRTY_BIT_PACK_STATE.

When copySubTextureCPUReadback (BlitGL.cpp:859) calls mFunctions->readPixels() into a scratch buffer, it reads with whatever PACK_ROW_LENGTH was last synced to native GL by a prior readPixels call. The scratch buffer is sized for width * height * 4 * 2 (32KB for 64x64), but native GL writes with stride PACK_ROW_LENGTH * 4, overflowing the buffer.

Steps to Reproduce

  1. Build Chromium with ASan (is_asan = true) or use an ASan-instrumented build.
  2. Save the attached poc.html.
  3. Run:
./chrome --no-sandbox --ignore-gpu-blocklist --single-process poc.html
  1. ASan reports heap-buffer-overflow (or heap-use-after-free) on the Chrome_InProcGp thread at BlitGL::copySubTextureCPUReadback.

No GPU hardware required. Reproduces on stock Linux VMs with Mesa llvmpipe.

ASan Output

PACK_ROW_LENGTH=4096. Full trace in asan_out.txt:

==175475==ERROR: AddressSanitizer: heap-use-after-free on address 0x75876315dab0
WRITE of size 8 at 0x75876315dab0 thread T17 (Chrome_InProcGp)
    #0 memcpy
    #1-#4 libgallium (Mesa readpixels)
    #5 rx::BlitGL::copySubTextureCPUReadback BlitGL.cpp:859
    #6 rx::TextureGL::copySubTextureHelper TextureGL.cpp:1150
    ...
    #12 GLES2DecoderPassthroughImpl::DoCopySubTextureCHROMIUM passthrough_doers.cc:4501

0x75876315dab0 is located 16 bytes inside of 29-byte region [0x75876315daa0,0x75876315dabd)
freed by thread T17 (Chrome_InProcGp) here:
    #0 operator delete
    #1 llvm::MCContext::reset()

MiraclePtr Status: NOT PROTECTED

Controlled bytes written at controlled offset past the scratch buffer. Landed in a freed LLVM MCContext region, likely reclaimable via heap spray.

Fix

Add pack state dirty bits to kTexImageDirtyBits:

constexpr state::DirtyBits kTexImageDirtyBits{
    state::DIRTY_BIT_UNPACK_STATE,
    state::DIRTY_BIT_UNPACK_BUFFER_BINDING,
    state::DIRTY_BIT_PACK_STATE,            // ADD
    state::DIRTY_BIT_PACK_BUFFER_BINDING,   // ADD
};

Or reset pack state in copySubTextureCPUReadback before readPixels (BlitGL.cpp:859), similar to how it already resets pack state after (line 873-876).

Bisect

Introduced in ANGLE commit https://chromium.googlesource.com/angle/angle/+/aadc8f376a (“Implement the CPU fallback for CopyTextureCHROMIUM on OpenGL”, 2017-08-11). This commit added copySubTextureCPUReadback with readPixels before setPixelPackState. Pack state was never reset before the read, from day one. Rolled into Chromium via https://chromium.googlesource.com/chromium/src/+/ed7971c95c05a (2017-08-30), shipping in Chrome 63 (December 2017).

The PBO bypass path that disables ScopedPackStateRowLengthReset when a PBO is bound was added in https://chromium.googlesource.com/chromium/src/+/5899dbdc3d96d (“Implement async ReadPixels for the passthrough command decoder”, 2017-10-05), making the stale state reachable.

Impact analysis

Heap overflow in the GPU process from unprivileged WebGL2 JavaScript, no compromised renderer required. The attacker controls the write data (source canvas pixels), write offset (PACK_ROW_LENGTH), and write size (source canvas width). The overflow target is a malloc’d scratch buffer with no MiraclePtr protection. On Linux, the GPU process is sandboxed but not as tightly as the renderer.


The cause

What version of Chrome have you found the security issue in?

147.0.7703.0 (Developer Build) (64-bit)

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a sandboxed process)

How would you like to be publicly acknowledged for your report?

cinzinga

View on issue tracker