CVE-2026-6296
Overview
Background
- ANGLE
- Chrome’s graphics abstraction layer that translates OpenGL ES calls onto native backends (here the desktop
GLbackend), 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 howglReadPixelswrites pixels into destination memory. - Pixel UNPACK state
- the analogous client state (
GL_UNPACK_*) that governs howglTexImage/glTexSubImagereads 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.
readPixels running under attacker-controlled PACK parameters against a tightly-sized buffer.Attack Path
- Trigger CPU readback
A renderer issues a copy (e.g.
copySubTexture/copyTexture) whose parameters force ANGLE onto thecopySubTextureCPUReadbackfallback path. - Poison PACK state
Before the copy, the client sets large
GL_PACK_ROW_LENGTH, non-zeroGL_PACK_SKIP_PIXELS/GL_PACK_SKIP_ROWS, andGL_PACK_ALIGNMENT = 8. - Bypassed neutralization
Because the UNPACK (not PACK) state was set before
readPixels, the poisoned client PACK state remains live for the internal readback. - Overflowing write
mFunctions->readPixelscomputes an inflated destination layout and writes beyond the fixed-size internalsourceMemoryheap allocation. - Heap corruption Adjacent GPU-process heap memory is overwritten, corrupting neighboring allocations.
Impact Assessment
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.cppsrc/tests/capture_replay_tests/capture_replay_expectations.txtsrc/tests/gl_tests/CopyTextureTest.cpp
Audit Directions
- State-neutralization orderingAudit every ANGLE fallback that mixes
readPixelsandtexImage/texSubImageto 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 buffersReview sites that size an internal
sourceMemory-style buffer assuming tight packing (alignment == 1, no row length/skip); verify no client-controllableGL_PACK_*/GL_UNPACK_*state can be live when that buffer is filled. - Client PBO and parameter leakageCheck 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.
Patch
From b149a5c62d76ab536929afc5bba2b2774da46102 Mon Sep 17 00:00:00 2001 From: Geoff Lang <[email protected]> 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 <[email protected]> Commit-Queue: Geoff Lang <[email protected]> --- 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) {
Regression Test / PoC
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)
{
Original Bug Report
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?
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:
- Set
PACK_ROW_LENGTH=N. Forwarded to ANGLE via command buffer (gles2_implementation.ccusesbreak, notreturn, for this param). - Bind PBO, then readPixels. This bypasses
ScopedPackStateRowLengthReset(disabled when PBO bound,passthrough_doers.cc:2661). ANGLE syncsPACK_ROW_LENGTH=Nto native GL. - Unbind and delete PBO.
StateManagerGL::deleteBufferunbinds PBO in native GL.PACK_ROW_LENGTHpersists. - Call
texImage2D(SRGB8_ALPHA8, webglCanvas). SRGB dest forces CPU readback path (TextureGL.cpp:1113,1133skip fast paths whendestSRGB=true). WebGL canvas source is texture-backed, routes throughCopySubTextureCHROMIUMtocopySubTextureCPUReadback. - Overflow.
BlitGL.cpp:859calls nativeglReadPixelswith stalePACK_ROW_LENGTH=N, strideN*4bytes per row into awidth*height*4*2byte 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
- Build Chromium with ASan (
is_asan = true) or use an ASan-instrumented build. - Save the attached
poc.html. - Run:
./chrome --no-sandbox --ignore-gpu-blocklist --single-process poc.html
- ASan reports
heap-buffer-overflow(orheap-use-after-free) on theChrome_InProcGpthread atBlitGL::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)
Is the security issue related to a crash?
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
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium.googlesource.com/angle/angle/+/aadc8f376a
- https://chromium.googlesource.com/chromium/src/+/5899dbdc3d96d
- https://chromium.googlesource.com/chromium/src/+/ed7971c95c05a
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp