Overview

Critical
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Sub-componentGL
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — 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 AIPlausible
Reviewer notes: Missing dirty bits on internal bindBuffer/bindBufferBase is confirmed, but the test exercises PIXEL_PACK/ReadPixels after an internal readback, not mainly TexImage/unpack. Writing past a 'smaller internal buffer' and attacker-shapeable overwrite are stated as fact without diff support. GL backend only.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker503768143
Fix commit1c82f3a0bd18 (angle/angle) +111/-0
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedMaher Azzouzi
Reported2026-04-17 · bounty $5000 (per vendor advisory)
Fix landed2026-04-21 (public git)
Disclosed2026-06-02
Patch gap42 days — fix public in source before the advisory

Background

`StateManagerGL`
ANGLE’s OpenGL-backend state manager that caches GL bindings and lazily flushes them to the driver via syncState.
`mLocalDirtyBits`
internal dirty-bit set that ANGLE ORs with frontend dirty bits so that only changed state is re-synchronized on the next syncState.
Internal buffer binding
a buffer ANGLE binds itself (for helper blits, staging, or internal copies) that transiently displaces the application’s bound buffer for a given target.
`GL_PIXEL_UNPACK_BUFFER` / `GL_PIXEL_PACK_BUFFER`
buffer targets that source pixel data for TexImage/TexSubImage uploads and sink pixel data for ReadPixels reads, respectively.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Missing dirty bits on internal bindBuffer/bindBufferBase is confirmed, but the test exercises PIXEL_PACK/ReadPixels after an internal readback, not mainly TexImage/unpack. Writing past a 'smaller internal buffer' and attacker-shapeable overwrite are stated as fact without diff support. GL backend only.

StateManagerGL tracks buffer bindings so that a client-visible binding is re-applied on the next syncState, but its internal bindBuffer/bindBufferBase helpers mutated the actual GL binding without recording a corresponding entry in mLocalDirtyBits.

As a result, when ANGLE bound one of its own internal buffers to GL_PIXEL_UNPACK_BUFFER (or GL_PIXEL_PACK_BUFFER) and later returned control to the application, the cached view of the binding was correct while the driver-side binding was stale, and no dirty bit forced a re-synchronization. A subsequent TexImage/TexSubImage therefore sourced pixel data from the wrong (internal or stale) unpack buffer instead of the application’s real buffer. Because the wrong buffer’s size and layout differ from the app buffer the transfer dimensions were computed against, the pixel-transfer engine reads/writes past the bounds of the mismatched buffer.

The fix adds setBufferBindingDirty(target), invoked from both bindBuffer and bindBufferBase, which sets the matching mLocalDirtyBits entry (DIRTY_BIT_UNPACK_BUFFER_BINDING, DIRTY_BIT_PACK_BUFFER_BINDING, and the uniform/atomic-counter/shader-storage/dispatch-indirect/draw-indirect bindings) so the next syncState restores the application binding.

Key insight
An internal binding change that is invisible to the dirty-bit machinery lets a stale, differently-sized buffer stay bound across the boundary into application-driven pixel transfers, so the size mismatch between the transfer geometry and the actually-bound buffer is what turns a caching bug into a memory-safety bug.

Attack Path

  1. Trigger internal bind Web content issues a WebGL/WebGPU operation that causes ANGLE to internally bind one of its own buffers to GL_PIXEL_UNPACK_BUFFER via bindBuffer/bindBufferBase.
  2. Skip re-sync Because no mLocalDirtyBits entry is set, the following syncState does not restore the application’s real unpack buffer binding.
  3. Issue mismatched upload The page calls TexImage/TexSubImage with dimensions sized for its own buffer while the smaller/differently-sized internal buffer remains bound as the pixel source.
  4. Out-of-bounds transfer The GPU-process pixel-transfer path indexes past the end of the mismatched buffer, producing an out-of-bounds write in the GPU process.
  5. Exploit The attacker shapes buffer sizes and transfer geometry to control the overwrite for memory corruption in the GPU process.

Impact Assessment

This is an out-of-bounds write in the ANGLE GL backend within the GPU process: pixel-transfer geometry computed for the application’s buffer is applied against a stale, smaller internal buffer, so the transfer writes past that buffer’s bounds. Because both the buffer sizes and the transfer dimensions are attacker-influenced through ordinary WebGL calls, the overwrite location and span are shapeable, making this exploitable for GPU-process memory corruption rather than a mere rendering artifact.

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

Audit Directions

  • Internal binding vs. dirty tracking
    Audit every path that mutates a cached GL binding directly (blit, staging, copy helpers) to confirm it feeds mLocalDirtyBits, so no internal bind can survive into application-visible state without a forced syncState.
  • Size-mismatched pixel transfers
    Review TexImage/TexSubImage/ReadPixels sinks and sources for the assumption that the currently-bound GL_PIXEL_UNPACK_BUFFER/GL_PIXEL_PACK_BUFFER matches the transfer geometry; a differing size yields an OOB read/write.
  • State-manager caches across trust boundaries
    Look for other lazily-synchronized caches in StateManagerGL where an internal side effect is not reflected in the dirty-bit set before returning control to untrusted client draws or transfers.
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