Critical patch-now ✔︎ verified CVSS 9.6 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in ANGLE
DescriptionHeap buffer overflow in ANGLE
ComponentANGLE
Sub-componentGL
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
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.
Independent review by AIConfirmed
Reviewer notes: Pack/unpack ordering bug confirmed by diff and commit message. Fix is limited to ANGLE's GL backend CPU-readback copy path. Diff shows neither WebGL reachability nor sourceMemory sizing; 'any context' and attacker-controlled overflow extent are overstated.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker490170083
Fix commitb149a5c62d76 (angle/angle) +53/-8
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
Creditedcinzinga
Reported2026-03-05 · bounty $90000 (per vendor advisory)
Fix landed2026-03-30 (public git)
Disclosed2026-04-15
Patch gap16 days — fix public in source before the advisory

Background

ANGLE
Chrome’s graphics abstraction layer that translates OpenGL ES calls onto native backends (here the desktop GL backend), running inside the sandboxed GPU process.
Pixel PACK state
the client-controllable GL state (GL_PACK_ROW_LENGTH, GL_PACK_SKIP_PIXELS, GL_PACK_SKIP_ROWS, GL_PACK_ALIGNMENT, and any bound pixel-pack buffer) that governs how glReadPixels writes pixels into destination memory.
Pixel UNPACK state
the analogous client state (GL_UNPACK_*) that governs how glTexImage/glTexSubImage reads pixels from source memory during uploads.
`copySubTextureCPUReadback`
an ANGLE fallback that copies one texture to another by reading the source to a CPU buffer (readPixels) and then re-uploading it (texImage).

Root Cause Analysis

rx::BlitGL::copySubTextureCPUReadback performs an internal readPixels followed by an internal texImage, and it must temporarily neutralize the client’s pixel PACK state before the readPixels and the client’s UNPACK state before the texImage, because otherwise the client’s parameters would corrupt the internal transfer. The internal sourceMemory buffer is sized under the assumption of a tightly packed layout (alignment == 1, no row length, no skip). Due to a state-setup ordering bug, the code set the UNPACK state before readPixels and only set the PACK state later before texImage, so at the moment readPixels executed the client’s real, unmodified PACK state was still active. A client that set inflated pack parameters (large GL_PACK_ROW_LENGTH, non-zero GL_PACK_SKIP_PIXELS/SKIP_ROWS, GL_PACK_ALIGNMENT of 8) therefore caused readPixels to compute a much larger stride/offset and write past the end of the fixed-size sourceMemory allocation. This produced a heap buffer overflow inside the GPU process.

The fix swaps the order so the PACK state is neutralized before readPixels and the UNPACK state before texImage.

Key insight
The two neutralization blocks were individually correct but applied in the wrong order, so each internal GL operation ran with the client’s un-neutralized state for the wrong pixel-transfer stage — the security-relevant window was the readPixels running under attacker-controlled PACK parameters against a tightly-sized buffer.

Attack Path

  1. Trigger CPU readback A renderer issues a copy (e.g. copySubTexture/copyTexture) whose parameters force ANGLE onto the copySubTextureCPUReadback fallback path.
  2. Poison PACK state Before the copy, the client sets large GL_PACK_ROW_LENGTH, non-zero GL_PACK_SKIP_PIXELS/GL_PACK_SKIP_ROWS, and GL_PACK_ALIGNMENT = 8.
  3. Bypassed neutralization Because the UNPACK (not PACK) state was set before readPixels, the poisoned client PACK state remains live for the internal readback.
  4. Overflowing write mFunctions->readPixels computes an inflated destination layout and writes beyond the fixed-size internal sourceMemory heap allocation.
  5. Heap corruption Adjacent GPU-process heap memory is overwritten, corrupting neighboring allocations.

Impact Assessment

This is a client-controllable heap buffer overflow in the GPU process, reachable from any context (including WebGL) that can drive a CPU-readback texture copy while setting standard pixel-pack parameters. Because the overflow length and offset are derived directly from attacker-chosen GL_PACK_* values, an attacker gains significant control over how far past sourceMemory the write extends, making it a strong primitive for GPU-process memory corruption and potential sandbox-constrained code execution.

Files Changed

  • src/libANGLE/renderer/gl/BlitGL.cpp
  • src/tests/capture_replay_tests/capture_replay_expectations.txt
  • src/tests/gl_tests/CopyTextureTest.cpp

Audit Directions

  • State-neutralization ordering
    Audit every ANGLE fallback that mixes readPixels and texImage/texSubImage to confirm PACK state is reset before each read and UNPACK state before each write, in that exact temporal order relative to the GL call.
  • Fixed-size internal transfer buffers
    Review sites that size an internal sourceMemory-style buffer assuming tight packing (alignment == 1, no row length/skip); verify no client-controllable GL_PACK_*/GL_UNPACK_* state can be live when that buffer is filled.
  • Client PBO and parameter leakage
    Check that internal transfers explicitly unbind client pixel-pack/unpack buffers (setPixelPackBuffer(nullptr)/setPixelUnpackBuffer(nullptr)) and clear row-length/skip/alignment before issuing internal transfer calls.
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