Overview

Critical
Severity
β€”
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker503768143
Fix commit1c82f3a0bd18 (angle/angle) +111/-0
CISA KEVNot listed
CreditedMaher Azzouzi
Disclosed2026-06-02

Files Changed

  • src/libANGLE/renderer/gl/StateManagerGL.cpp
  • src/libANGLE/renderer/gl/StateManagerGL.h
  • src/tests/angle_end2end_tests_expectations.txt
  • src/tests/capture_replay_tests/capture_replay_expectations.txt
  • src/tests/gl_tests/CopyTextureTest.cpp
From 1c82f3a0bd18d2046fa38ccffffbcc45891f0301 Mon Sep 17 00:00:00 2001
From: Geoff Lang <[email protected]>
Date: Fri, 17 Apr 2026 18:03:58 -0400
Subject: [PATCH] GL: Mark StateManagerGL internal buffer state dirty on bind

When changing state, StateManagerGL would set local dirty bits which
would be synchronized on the next syncState by ORing them with the
frontend dirty bits. This was not done for internal buffer binding
changes and allowed for incorrect pixel buffers to be bound on
ReadPixels or TexImage calls.

Fixed: chromium:498904293
Fixed: chromium:503768143
Change-Id: I42f5acfdb709f327205f0f8cc04c3f11f1bd2b79
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7774027
Commit-Queue: Geoff Lang <[email protected]>
Reviewed-by: Shahbaz Youssefi <[email protected]>
---

diff --git a/src/libANGLE/renderer/gl/StateManagerGL.cpp b/src/libANGLE/renderer/gl/StateManagerGL.cpp
index c0dea01..0d2bf2a 100644
--- a/src/libANGLE/renderer/gl/StateManagerGL.cpp
+++ b/src/libANGLE/renderer/gl/StateManagerGL.cpp
@@ -473,6 +473,7 @@
     {
         mBuffers[target] = buffer;
         mFunctions->bindBuffer(gl::ToGLenum(target), buffer);
+        setBufferBindingDirty(target);
     }
 }
 
@@ -491,6 +492,7 @@
         binding.size     = static_cast<size_t>(-1);
         mBuffers[target] = buffer;
         mFunctions->bindBufferBase(gl::ToGLenum(target), static_cast<GLuint>(index), buffer);
+        setBufferBindingDirty(target);
     }
 }
 
@@ -2949,6 +2951,56 @@
                         "GL_VERTEX_ARRAY_BINDING");
 }
 
+void StateManagerGL::setBufferBindingDirty(gl::BufferBinding binding)
+{
+    switch (binding)
+    {
+        case gl::BufferBinding::Array:
+            // Nothing to do. Array buffer bindings are set before vertex attrib calls.
+            break;
+        case gl::BufferBinding::AtomicCounter:
+            mLocalDirtyBits.set(gl::state::DIRTY_BIT_ATOMIC_COUNTER_BUFFER_BINDING);
+            break;
+        case gl::BufferBinding::CopyRead:
+            // Nothing to do. CopyRead does not affect any operations.
+            break;
+        case gl::BufferBinding::CopyWrite:
+            // Nothing to do. CopyWrite does not affect any operations.
+            break;
+        case gl::BufferBinding::DispatchIndirect:
+            mLocalDirtyBits.set(gl::state::DIRTY_BIT_DISPATCH_INDIRECT_BUFFER_BINDING);
+            break;
+        case gl::BufferBinding::DrawIndirect:
+            mLocalDirtyBits.set(gl::state::DIRTY_BIT_DRAW_INDIRECT_BUFFER_BINDING);
+            break;
+        case gl::BufferBinding::ElementArray:
+            // Managed by the VAO
+            break;
+        case gl::BufferBinding::PixelPack:
+            mLocalDirtyBits.set(gl::state::DIRTY_BIT_PACK_BUFFER_BINDING);
+            break;
+        case gl::BufferBinding::PixelUnpack:
+            mLocalDirtyBits.set(gl::state::DIRTY_BIT_UNPACK_BUFFER_BINDING);
+            break;
+        case gl::BufferBinding::ShaderStorage:
+            mLocalDirtyBits.set(gl::state::DIRTY_BIT_SHADER_STORAGE_BUFFER_BINDING);
+            break;
+        case gl::BufferBinding::Texture:
+            // Not implemented in the GL backend
+            UNREACHABLE();
+            break;
+        case gl::BufferBinding::TransformFeedback:
+            // Transform feedback buffer bindings are tracked in TransformFeedbackGL
+            UNREACHABLE();
+            break;
+        case gl::BufferBinding::Uniform:
+            mLocalDirtyBits.set(gl::state::DIRTY_BIT_UNIFORM_BUFFER_BINDINGS);
+            break;
+        default:
+            UNREACHABLE();
+    }
+}
+
 template <>
 void StateManagerGL::get(GLenum name, GLboolean *value)
 {
diff --git a/src/libANGLE/renderer/gl/StateManagerGL.h b/src/libANGLE/renderer/gl/StateManagerGL.h
index bad9aa5..795a690 100644
--- a/src/libANGLE/renderer/gl/StateManagerGL.h
+++ b/src/libANGLE/renderer/gl/StateManagerGL.h
@@ -365,6 +365,8 @@
     void updateDispatchIndirectBufferBinding(const gl::Context *context);
     void updateDrawIndirectBufferBinding(const gl::Context *context);
 
+    void setBufferBindingDirty(gl::BufferBinding binding);
+
     template <typename T>
     void get(GLenum name, T *value);
 
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 003d9b3..3e44278 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -1561,6 +1561,9 @@
 42265943 WIN INTEL OPENGL : CompressedTextureETC2RGBA8SRGBTest.Test/* = SKIP
 42265943 WIN INTEL OPENGL : CompressedTextureETC2RGBA8Test.Test/* = SKIP
 
+// NVIDIA GLES specific failures with DXT texture uploads
+498904293 WIN NVIDIA GLES : DXT1CompressedTextureTestWebGL2.InitializeTextureContents/* = SKIP
+
 // Takes several minutes to complete with ASAN / TSAN
 42263997 ASAN VULKAN SWIFTSHADER : ClipCullDistanceTest.SizeCheckCombined/* = SKIP
 42266388 TSAN VULKAN SWIFTSHADER : ClipCullDistanceTest.SizeCheckCombined/* = SKIP
diff --git a/src/tests/capture_replay_tests/capture_replay_expectations.txt b/src/tests/capture_replay_tests/capture_replay_expectations.txt
index 201c64a..5df1eab 100644
--- a/src/tests/capture_replay_tests/capture_replay_expectations.txt
+++ b/src/tests/capture_replay_tests/capture_replay_expectations.txt
@@ -81,6 +81,7 @@
 42264831 : FramebufferTest_ES3.ChangeAttachmentThenInvalidateAndDraw/* = SKIP_FOR_CAPTURE
 42264831 : FramebufferTest_ES3.RenderAndInvalidateImmutableTextureWithBeyondMaxLevel/* = SKIP_FOR_CAPTURE
 490170083 : CopyTextureTestES3.SRGBWithPackParameters/* = SKIP_FOR_CAPTURE
+498904293 : CopyTextureTestES3.PBOSynchronization/* = 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 07d490c..6df23a9 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -2725,6 +2725,59 @@
     EXPECT_PIXEL_COLOR_EQ(0, 0, kSourceColor);
 }
 
+// Test that copies that trigger internal readbacks do not interfere with the frontend PBO state.
+TEST_P(CopyTextureTestES3, PBOSynchronization)
+{
+    ANGLE_SKIP_TEST_IF(!checkExtensions());
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_EXT_sRGB"));
+
+    GLTexture backbufferTex;
+    glBindTexture(GL_TEXTURE_2D, backbufferTex);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, backbufferTex, 0);
+
+    glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
+    glClear(GL_COLOR_BUFFER_BIT);
+    EXPECT_GL_NO_ERROR();
+
+    // Set the PBO and write some data to it. It will be synchronized in the backend
+    GLBuffer pbo;
+    glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
+    glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
+    glBufferData(GL_PIXEL_PACK_BUFFER, 4096, nullptr, GL_STATIC_DRAW);
+    glReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, 0);
+
+    // Copy into an SRGB texture which does an internal readback
+    glBindTexture(GL_TEXTURE_2D, mTextures[1]);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    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();
+
+    glCopySubTextureCHROMIUM(mTextures[1], 0, GL_TEXTURE_2D, mTextures[0], 0, 0, 0, 0, 0, 1, 1,
+                             false, false, false);
+    EXPECT_GL_NO_ERROR();
+
+    // Read pixels again, should still go to the PBO
+    glReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, reinterpret_cast<GLvoid *>(1024));
+
+    uint8_t *mappedPtr =
+        static_cast<uint8_t *>(glMapBufferRangeEXT(GL_PIXEL_PACK_BUFFER, 0, 4096, GL_MAP_READ_BIT));
+    EXPECT_GL_NO_ERROR();
+
+    EXPECT_EQ(GLColor::red, *reinterpret_cast<GLColor *>(mappedPtr));
+    EXPECT_EQ(GLColor::red, *reinterpret_cast<GLColor *>(mappedPtr + 1024));
+
+    glUnmapBufferOES(GL_PIXEL_PACK_BUFFER);
+    EXPECT_GL_NO_ERROR();
+}
+
 // Test that the right error type is triggered when
 // OES_EGL_image_external_essl3 is required but not supported
 TEST_P(CopyTextureTestES3, CopySubTextureMissingRequiredExtension)
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 003d9b3..3e44278 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -1561,6 +1561,9 @@
 42265943 WIN INTEL OPENGL : CompressedTextureETC2RGBA8SRGBTest.Test/* = SKIP
 42265943 WIN INTEL OPENGL : CompressedTextureETC2RGBA8Test.Test/* = SKIP
 
+// NVIDIA GLES specific failures with DXT texture uploads
+498904293 WIN NVIDIA GLES : DXT1CompressedTextureTestWebGL2.InitializeTextureContents/* = SKIP
+
 // Takes several minutes to complete with ASAN / TSAN
 42263997 ASAN VULKAN SWIFTSHADER : ClipCullDistanceTest.SizeCheckCombined/* = SKIP
 42266388 TSAN VULKAN SWIFTSHADER : ClipCullDistanceTest.SizeCheckCombined/* = SKIP
diff --git a/src/tests/capture_replay_tests/capture_replay_expectations.txt b/src/tests/capture_replay_tests/capture_replay_expectations.txt
index 201c64a..5df1eab 100644
--- a/src/tests/capture_replay_tests/capture_replay_expectations.txt
+++ b/src/tests/capture_replay_tests/capture_replay_expectations.txt
@@ -81,6 +81,7 @@
 42264831 : FramebufferTest_ES3.ChangeAttachmentThenInvalidateAndDraw/* = SKIP_FOR_CAPTURE
 42264831 : FramebufferTest_ES3.RenderAndInvalidateImmutableTextureWithBeyondMaxLevel/* = SKIP_FOR_CAPTURE
 490170083 : CopyTextureTestES3.SRGBWithPackParameters/* = SKIP_FOR_CAPTURE
+498904293 : CopyTextureTestES3.PBOSynchronization/* = 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 07d490c..6df23a9 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -2725,6 +2725,59 @@
     EXPECT_PIXEL_COLOR_EQ(0, 0, kSourceColor);
 }
 
+// Test that copies that trigger internal readbacks do not interfere with the frontend PBO state.
+TEST_P(CopyTextureTestES3, PBOSynchronization)
+{
+    ANGLE_SKIP_TEST_IF(!checkExtensions());
+    ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_EXT_sRGB"));
+
+    GLTexture backbufferTex;
+    glBindTexture(GL_TEXTURE_2D, backbufferTex);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, backbufferTex, 0);
+
+    glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
+    glClear(GL_COLOR_BUFFER_BIT);
+    EXPECT_GL_NO_ERROR();
+
+    // Set the PBO and write some data to it. It will be synchronized in the backend
+    GLBuffer pbo;
+    glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
+    glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
+    glBufferData(GL_PIXEL_PACK_BUFFER, 4096, nullptr, GL_STATIC_DRAW);
+    glReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, 0);
+
+    // Copy into an SRGB texture which does an internal readback
+    glBindTexture(GL_TEXTURE_2D, mTextures[1]);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    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();
+
+    glCopySubTextureCHROMIUM(mTextures[1], 0, GL_TEXTURE_2D, mTextures[0], 0, 0, 0, 0, 0, 1, 1,
+                             false, false, false);
+    EXPECT_GL_NO_ERROR();
+
+    // Read pixels again, should still go to the PBO
+    glReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, reinterpret_cast<GLvoid *>(1024));
+
+    uint8_t *mappedPtr =
+        static_cast<uint8_t *>(glMapBufferRangeEXT(GL_PIXEL_PACK_BUFFER, 0, 4096, GL_MAP_READ_BIT));
+    EXPECT_GL_NO_ERROR();
+
+    EXPECT_EQ(GLColor::red, *reinterpret_cast<GLColor *>(mappedPtr));
+    EXPECT_EQ(GLColor::red, *reinterpret_cast<GLColor *>(mappedPtr + 1024));
+
+    glUnmapBufferOES(GL_PIXEL_PACK_BUFFER);
+    EXPECT_GL_NO_ERROR();
+}
+
 // Test that the right error type is triggered when
 // OES_EGL_image_external_essl3 is required but not supported
 TEST_P(CopyTextureTestES3, CopySubTextureMissingRequiredExtension)
Loading diff…

Original Bug Report

reported by [email protected]

ANGLE: missing setPixelPackBuffer(nullptr) in norm16 readback workaround causes GPU process crash via WebGL PBO type confusion


Report description

ANGLE: missing setPixelPackBuffer(nullptr) in norm16 readback workaround causes GPU process crash via WebGL PBO type confusion


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://chromium.googlesource.com/angle/angle


The problem

Please describe the technical details of the vulnerability

Type Confusion in ANGLE EXT_texture_norm16 Readback Workaround Leading to GPU OOB Write

Component: ANGLE (Almost Native Graphics Layer Engine) File: src/libANGLE/renderer/gl/FramebufferGL.cpp Functions: readPixelsRowByRow (line 1683), readPixelsAllAtOnce (lines 1744, 1754) Vulnerability class: Type confusion β†’ GPU out-of-bounds write at ~heap address Distinct from: CVE-2026-6296


Summary

When the EXT_texture_norm16 readback workaround is active, ANGLE allocates a temporary CPU heap buffer (tmpPixels) and passes its address to the native glReadPixels call as the pixels parameter. However, if the caller has a GL_PIXEL_PACK_BUFFER bound, ANGLE fails to clear that binding before the internal glReadPixels call. The native GL driver then type-confuses the CPU heap pointer (a virtual address, e.g. 0x6d3781cf9670) as a byte offset into the GPU PBO, writing pixel data to an out-of-bounds location ~120 TB into the buffer object. This constitutes a GPU out-of-bounds write at the heap address.


Root Cause

In both readPixelsRowByRow and readPixelsAllAtOnce, ANGLE correctly resets pack alignment via setPixelPackState(context, directPack) before issuing the internal glReadPixels call, but never calls setPixelPackBuffer(context, nullptr). As a result, any GL_PIXEL_PACK_BUFFER bound by the caller remains bound in native GL state.

setPixelPackState only issues glPixelStorei calls (alignment, rowLength, etc.) and does not touch buffer bindings (StateManagerGL.cpp:647-682). Buffer bindings are only cleared by setPixelPackBuffer (StateManagerGL.cpp:685-693), which is absent from both paths.

A safe reference implementation exists at BlitGL.cpp:862-865 (the CVE-2026-6296 fix), which explicitly calls both setPixelPackState and setPixelPackBuffer(context, nullptr) before any internal readPixels.


Type Confusion

The confusion is between two incompatible interpretations of the pixels parameter to glReadPixels:

Context Interpretation of pixels
No PBO bound (intended) CPU virtual address β€” native GL writes directly to this heap pointer
PBO bound (actual, due to missing clear) Byte offset into the bound PBO buffer object

ANGLE allocates tmpPixels as a CPU heap buffer and passes tmpPixels + skipBytes as pixels, intending interpretation (1). Because the PBO is still bound, the native driver applies interpretation (2), treating the heap virtual address (~0x6d3781cf9670) as a byte offset into the GPU buffer β€” an offset orders of magnitude larger than the PBO’s allocated size.


Source β†’ Sink Call Chain

The confirmed path (no PACK_ROW_LENGTH required, hits readPixelsAllAtOnce):

JavaScript (WebGL2)
  gl.bindBuffer(PIXEL_PACK_BUFFER, pbo)           // PBO bound
  gl.readPixels(0, 0, 1, 1, GL_RGBA, UNSIGNED_SHORT, 0x10000)

Context::readPixels [Context.cpp]
  packBuffer != nullptr
  readFBO->readPixels(ctx, area, GL_RGBA, GL_UNSIGNED_SHORT, packState, packBuffer, (void*)0x10000)

FramebufferGL::readPixels [FramebufferGL.cpp:779]
  attachmentReadFormat = GL_RED  (R16_EXT base format)
  GetNativeReadFormat() returns GL_RED  (norm16 workaround active)
  readFormat = GL_RED, originalReadFormat = GL_RGBA
  cannotSetDesiredRowLength = false  (packSubimageNV present on GLES 3.0+)
  -> readPixelsAllAtOnce(ctx, area, GL_RGBA, GL_RED, GL_UNSIGNED_SHORT, packState, pixels=(void*)0x10000)

readPixelsAllAtOnce [FramebufferGL.cpp:1708]
  workaround.Initialize() -> enabled=true, tmpPixels=new GLubyte[N]  // heap alloc e.g. 0x6d3781cf9670
  setPixelPackState(pack)       // alignment reset -- OK
  // *** setPixelPackBuffer(nullptr) NEVER CALLED ***
  functions->readPixels(..., workaround.Pixels())  // pixels = tmpPixels = 0x6d3781cf9670
    // native GL: PBO still bound
    // driver interprets 0x6d3781cf9670 as PBO byte offset
    // -> GL_INVALID_OPERATION (offset >> PBO size) on drivers with bounds checking
    // -> GPU OOB write on mobile GLES drivers without full PBO bounds checking
    // tmpPixels on CPU remains zeroed either way

RearrangeEXTTextureNorm16Pixels(..., clientPixels=(GLubyte*)0x10000, tmpPixels=all-zeros)
  dstRowStart = (GLubyte*)0x10000 + originalReadFormatSkipBytes
  dstPixel[0] = srcPixel[0]   // zero-write to address 0x10000 in GPU process -> crash

Impact

GPU side: The native glReadPixels call with a PBO bound and pixels = (void*)<heap_address> causes the GPU to write pixel data into the PBO at a byte offset equal to the heap virtual address (~120 TB). On GPU drivers without full IOMMU coverage (common on mobile ARM), this write reaches arbitrary GPU-visible memory, corrupting adjacent GPU allocations or other processes’ GPU resources. On drivers with proper bounds checking, this triggers a GPU fault.

CPU side: RearrangeEXTTextureNorm16Pixels subsequently treats clientPixels (the original PBO byte offset cast to a pointer) as a CPU write destination in the GPU process. The write lands at pbo_offset + originalReadFormatSkipBytes. pbo_offset is attacker-controlled via the offset parameter to gl.readPixels (bounded by PBO size), producing a controlled-address zero-write in the GPU process that crashes it.


Trigger Conditions

All of the following must hold simultaneously:

Condition How to satisfy (WebGL2)
GL_EXT_texture_norm16 attached to FBO gl.texStorage2D(TEXTURE_2D, 1, R16_EXT, w, h)
readPixelsUsingImplementationColorReadFormatForNorm16 workaround active Enabled by default on Android/ChromeOS GLES drivers that lack native GL_RGBA readback from norm16 FBOs
GL_PIXEL_PACK_BUFFER bound gl.bindBuffer(PIXEL_PACK_BUFFER, pbo)
Read with GL_RGBA / GL_UNSIGNED_SHORT gl.readPixels(..., gl.RGBA, gl.UNSIGNED_SHORT, offset)

Note: cannotSetDesiredRowLength is not required. Both readPixelsRowByRow and readPixelsAllAtOnce are vulnerable. The confirmed Chrome crash path uses readPixelsAllAtOnce which requires no special pack state.


Proof of Concept

Minimal trigger β€” no special pack state required:

const ext = gl.getExtension('EXT_texture_norm16');

const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texStorage2D(gl.TEXTURE_2D, 1, ext.R16_EXT, 1, 1);

const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);

const PBO_OFFSET = 0x10000;  // write lands at address 0x10000 in GPU process β€” not null
const pbo = gl.createBuffer();
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo);
gl.bufferData(gl.PIXEL_PACK_BUFFER, PBO_OFFSET + 8, gl.STREAM_READ);

gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_SHORT, PBO_OFFSET);
// -> GPU process crash (webglcontextlost), tab survives

Confirmed on Windows + NVIDIA GeForce RTX 5050, Chrome 147, --use-angle=gl with ANGLE_FEATURE_OVERRIDES_ENABLED=readPixelsUsingImplementationColorReadFormatForNorm16. On Android/ChromeOS with vulnerable GLES drivers the feature is enabled by default β€” no env var required.

ASan output from ANGLE end-to-end test confirms crash at RearrangeEXTTextureNorm16Pixels line 462 (dstPixel[0] = srcPixel[0]).


Distinction from CVE-2026-6296

CVE-2026-6296 This vulnerability
Location BlitGL::copySubTextureCPUReadback FramebufferGL::readPixelsRowByRow / readPixelsAllAtOnce
Root cause Pack alignment not reset β†’ heap buffer sized for alignment=1 but written with user alignment β†’ heap overflow past end of allocation PBO binding not cleared β†’ heap pointer type-confused as GPU buffer offset β†’ GPU OOB write at ~heap address
Corruption type CPU heap overflow (write just past end of buffer) GPU OOB write at ~heap address + CPU write at attacker-influenced address
Missing fix setPixelPackState(alignment=1) setPixelPackBuffer(nullptr)

Proposed Fix

Add setPixelPackBuffer(context, nullptr) immediately after each setPixelPackState call that precedes an internal functions->readPixels call, mirroring the pattern from BlitGL.cpp:862-865:

// readPixelsRowByRow (line 1685):
ANGLE_TRY(stateManager->setPixelPackState(context, directPack));
ANGLE_TRY(stateManager->setPixelPackBuffer(context, nullptr));  // ADD

// readPixelsAllAtOnce height > 0 branch (line 1744):
ANGLE_TRY(stateManager->setPixelPackState(context, pack));
ANGLE_TRY(stateManager->setPixelPackBuffer(context, nullptr));  // ADD

// readPixelsAllAtOnce readLastRowSeparately branch (line 1754):
ANGLE_TRY(stateManager->setPixelPackState(context, directPack));
ANGLE_TRY(stateManager->setPixelPackBuffer(context, nullptr));  // ADD

Confirmed Chrome WebGL Reproduction

The vulnerability was confirmed triggered from Chrome WebGL on Windows + NVIDIA GeForce RTX 5050 with --use-angle=gl and ANGLE_FEATURE_OVERRIDES_ENABLED=readPixelsUsingImplementationColorReadFormatForNorm16.

The env var is required on Windows because Chrome’s driver bug list disables the feature for NVIDIA desktop OpenGL (the driver handles norm16 readback correctly). On Android and ChromeOS devices where the underlying GLES driver has the norm16 readback bug, Chrome cannot disable the workaround β€” it is enabled by default and no env var is required. Those platforms are the primary real-world attack surface.

Observed from the WebGL PoC:

  • PBO readback returns all zeros β€” GPU driver rejected heap address as PBO byte offset (GL_INVALID_OPERATION), tmpPixels was never filled
  • webglcontextlost event fires β€” GPU process crashed from the subsequent write to (GLubyte*)pbo_offset in RearrangeEXTTextureNorm16Pixels
  • Chrome restarts GPU process β€” tab survives, webglcontextrestored fires

Write Occurs Before Tab Crash

The GPU out-of-bounds write at step (1) completes and returns before the renderer tab crashes at step (2):

(1) functions->readPixels(..., tmpPixels)
      β†’ PBO still bound β†’ driver interprets heap address as PBO byte offset
      β†’ On mobile GLES without full PBO bounds checking: GPU OOB write at ~heap address
      β†’ On desktop GL / drivers with bounds checking: GL_INVALID_OPERATION returned
      β†’ Either way, tmpPixels on CPU remains zeroed, execution continues

(2) RearrangeEXTTextureNorm16Pixels writes zeros to (GLubyte*)pbo_offset
      β†’ controlled-address zero-write in GPU process
      β†’ GPU process crash (confirmed via webglcontextlost in Chrome)

This means the vulnerability is not a pure denial-of-service. The GPU memory write with attacker-controlled data (the R16 texture contents) is committed prior to any visible crash. Additionally, the CPU-side write destination is attacker-controlled via the glReadPixels byte offset parameter (bounded by PBO_size <= GPU memory), giving a controlled-address zero-write in the GPU process. The value written is always zero (because tmpPixels is never filled by the GPU), but the address is chosen by the attacker within [0, PBO_size - endByte]. This is sufficient to null out pointers or corrupt heap metadata at a chosen location in the GPU process, and is meaningfully stronger than a fixed null dereference.

Impact analysis

When this vulnerability is triggered from a malicious web page, it causes an out-of-bounds write into GPU memory and crashes Chrome’s GPU process. The GPU write completes before the crash, so this is not a pure denial-of-service β€” memory corruption occurs prior to any visible effect. The write destination in the GPU process is controlled by the attacker via the PBO offset parameter. The primary attack surface is Android and ChromeOS, where the vulnerable code path is enabled by default with no special configuration required.


The cause

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

147.0.7727.102

No, it is not related to a crash.

Choose the type of vulnerability

Memory Corruption

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

Maher Azzouzi

View on issue tracker