CVE-2026-10883
Overview
Files Changed
src/libANGLE/renderer/gl/StateManagerGL.cppsrc/libANGLE/renderer/gl/StateManagerGL.hsrc/tests/angle_end2end_tests_expectations.txtsrc/tests/capture_replay_tests/capture_replay_expectations.txtsrc/tests/gl_tests/CopyTextureTest.cpp
Patch
From 1c82f3a0bd18d2046fa38ccffffbcc45891f0301 Mon Sep 17 00:00:00 2001
From: Geoff Lang <geofflang@chromium.org>
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 <geofflang@chromium.org>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
---
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)
Regression Test / PoC
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)
Original Bug Report
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),tmpPixelswas never filled webglcontextlostevent fires β GPU process crashed from the subsequent write to(GLubyte*)pbo_offsetinRearrangeEXTTextureNorm16Pixels- Chrome restarts GPU process β tab survives,
webglcontextrestoredfires
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
Is the security issue related to a crash?
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