Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read and write in ANGLE
DescriptionOut of bounds read and 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: Only affects ANGLE's GL/GLES backend (Linux/Android, not D3D/Metal/Vulkan) yet claimed unqualified default; needs WebGL2 PBOs. 'Scratch buffer size mismatch' is speculation; buffer 0 bound would make PBO offset a raw pointer. Diff-shown trigger: internal readback (sRGB CopyTexture).
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker498904293
Fix commit1c82f3a0bd18 (angle/angle) +111/-0
CWECWE-125, CWE-787 (Out-of-bounds read, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedAnonymous
Reported2026-04-02 · bounty $97000 (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 GL-backend cache of the underlying driver’s GL state, tracking which buffers are bound so redundant glBind* calls can be elided.
`mLocalDirtyBits`
a bitset of state changes made internally by the GL backend that is ORed with the frontend’s dirty bits at the next syncState, telling syncState which state must be re-synchronized to the application’s intent.
`syncState`
the routine that reconciles ANGLE’s frontend (application) state onto the driver by acting on the union of frontend and local dirty bits; a binding not marked dirty is assumed already correct and is skipped.
`PIXEL_PACK` / `PIXEL_UNPACK` buffer
the buffer a ReadPixels transfer writes into (pack) or a TexImage transfer reads from (unpack); its size must accommodate the transfer.

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: Only affects ANGLE's GL/GLES backend (Linux/Android, not D3D/Metal/Vulkan) yet claimed unqualified default; needs WebGL2 PBOs. 'Scratch buffer size mismatch' is speculation; buffer 0 bound would make PBO offset a raw pointer. Diff-shown trigger: internal readback (sRGB CopyTexture).

When StateManagerGL::bindBuffer or StateManagerGL::bindBufferBase performed an ANGLE-internal binding change, it updated its local cache (mBuffers[target] = buffer) and issued the native bindBuffer/bindBufferBase, but it did not set any bit in mLocalDirtyBits for that target. Because the pack/unpack (and other indexed) buffer binding was never marked dirty, the subsequent syncState saw no dirty bit for that binding and therefore did not restore the application’s real PIXEL_PACK/PIXEL_UNPACK buffer. The driver was thus left with ANGLE’s internal scratch buffer — or a stale prior binding — bound when the next client ReadPixels or TexImage executed. Since the internal/stale buffer’s size does not match the client transfer, the transfer’s offset and length exceed the buffer’s bounds, causing an out-of-bounds read (unpack/TexImage source) or write (pack/ReadPixels destination).

The fix adds setBufferBindingDirty(target) after every internal bind so the corresponding dirty bit is set and syncState re-synchronizes the binding.

Key insight
A cache that mirrors driver state must also record that the state diverged from the application’s intent; updating mBuffers[target] without setting the matching mLocalDirtyBits bit made syncState silently trust a binding it had itself clobbered.

Attack Path

  1. Trigger internal bind Client issues an operation that makes ANGLE internally bind its own scratch PIXEL_PACK/PIXEL_UNPACK buffer via bindBuffer/bindBufferBase.
  2. Dirty bit missed The internal bind updates mBuffers but leaves mLocalDirtyBits clear for that target.
  3. syncState skips restore The next syncState sees no dirty bit and does not rebind the application’s real pack/unpack buffer.
  4. Mismatched transfer Client calls ReadPixels or TexImage, which runs against the wrong (internal or stale) buffer whose size does not match the requested transfer.
  5. OOB read/write The pixel transfer reads or writes past the bound buffer’s bounds, corrupting or leaking adjacent memory.

Impact Assessment

An attacker-controlled sequence of GL calls from a renderer (e.g. via WebGL) can leave a mismatched pixel buffer bound and then drive a ReadPixels/TexImage transfer that reads out of bounds (information disclosure) and writes out of bounds (memory corruption). Because both primitives are reachable and the transfer size and buffer are influenceable, this is a strong exploitation primitive that can lead to sandboxed renderer compromise.

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

  • Cache-update without dirty-mark
    Audit every site that writes ANGLE’s cached driver state (mBuffers, texture, sampler, framebuffer caches) for a companion mLocalDirtyBits update; a mutation missing its dirty bit desynchronizes syncState.
  • Internal state save/restore
    Review ANGLE-internal operations that transiently bind their own scratch resources (ReadPixels, TexImage, blit, mipmap generation) to confirm the application’s binding is marked dirty and restored afterward.
  • Enum-to-bit mapping completeness
    Verify setBufferBindingDirty and similar switch-on-gl::BufferBinding mappings handle every binding whose desync is security-relevant, and that intentional no-op cases (Array/CopyRead/CopyWrite/ElementArray) are genuinely safe to skip.
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]

Arbitrary Memory Read and Write in ANGLE GL Backend via PBO Desync

VULNERABILITY DETAILS

A severe state desynchronization vulnerability exists in the ANGLE OpenGL backend (CopySubTextureCHROMIUM), leading to Arbitrary R/W in the GPU process.

The root cause resides in BlitGL::copySubTextureCPUReadback. When performing a CPU fallback copy, the code explicitly unbinds the PIXEL_PACK_BUFFER mappings through the GL backend StateManagerGL using setPixelPackBuffer(context, nullptr) [0]. An identical mechanism unbinds the PIXEL_UNPACK_BUFFER via setPixelUnpackBuffer [1].

    gl::PixelPackState pack;
    pack.alignment = 1;
    ANGLE_TRY(mStateManager->setPixelPackState(context, pack));
    ANGLE_TRY(mStateManager->setPixelPackBuffer(context, nullptr)); // [0]

    // ...
    gl::PixelUnpackState unpack;
    unpack.alignment = 1;
    ANGLE_TRY(mStateManager->setPixelUnpackState(context, unpack));
    ANGLE_TRY(mStateManager->setPixelUnpackBuffer(context, nullptr)); // [1]

These functions effectively propagate the glBindBuffer command directly to the native GPU driver via StateManagerGL::bindBuffer [2].

angle::Result StateManagerGL::setPixelUnpackBuffer(const gl::Context *context,
                                                   const gl::Buffer *pixelBuffer)
{
    GLuint bufferID = 0;
    if (pixelBuffer != nullptr)
    {
        bufferID = GetImplAs<BufferGL>(pixelBuffer)->getBufferID();
    }
    bindBuffer(gl::BufferBinding::PixelUnpack, bufferID); // [2]

    return angle::Result::Continue;
}

However, they silently bypass the ANGLE frontend tracking system (gl::State). Crucially, the frontend’s dirty bits (such as DIRTY_BIT_UNPACK_BUFFER_BINDING) are never set.

Because the frontend gl::State tracking and the native OpenGL driver state become desynchronized, the underlying native GL driver lacks the PBO binding (it was set to 0), while the frontend continues to believe the PBO is actively bound. When a subsequent API call takes an offset, the native driver treats the user-supplied offset as a raw CPU/Host pointer, enabling arbitrary memory operations.

[0] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp;drc=a76e73df11cbb5466e8a1c8b3c9c04ce4a981f7b;l=865

[1] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp;drc=a76e73df11cbb5466e8a1c8b3c9c04ce4a981f7b;l=883

[2] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/gl/StateManagerGL.cpp;drc=a76e73df11cbb5466e8a1c8b3c9c04ce4a981f7b;l=642

Vulnerability Exploit

The PoC leverages the desynchronization bug to transform a PBO offset into an arbitrary memory pointer. The process relies on intentionally triggering a CPU fallback path (e.g., via copyTextureCHROMIUM), which silently unbinds the PIXEL_PACK_BUFFER in the native OpenGL driver while the ANGLE frontend state remains intact.

Steps to achieve an arbitrary write:

  1. Desynchronize the PIXEL_PACK_BUFFER state.
  2. Manipulate the frontend state system (e.g., via dummy readPixels calls) to clear dirty bits, preventing the buffer from re-binding to the driver.
  3. Call readPixels using a controlled integer (e.g. 0x41414141) as the offset.
  4. Since the native OpenGL driver considers the PBO unbound (id = 0), it interprets the offset as an absolute virtual memory pointer, writing pixel data directly to this address.

The arbitrary read is achieved via a similar state desynchronization for PIXEL_UNPACK_BUFFER during operations like texSubImage2D.

Arbitrary Write Crash Site

Thread 1 "chrome" received signal SIGSEGV, Segmentation fault.
__memcpy_avx_unaligned_erms ()
    at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:271
warning: 271	../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S: No such file or directory
(gdb) x/i $pc
=> 0x7f264e788aa1 <__memcpy_avx_unaligned_erms+33>:	vmovdqu %ymm0,(%rdi)
(gdb) i r rdi
rdi            0x41414141          1094795585
(gdb) i r ymm0
ymm0           {v16_bfloat16 = {0x4242 <repeats 16 times>}, v16_half = {0x4242 <repeats 16 times>}, v8_float = {0x42424242, 0x42424242, 0x42424242, 0x42424242, 0x42424242, 0x42424242, 0x42424242, 0x42424242}, v4_double = {0x4242424242424242, 0x4242424242424242, 0x4242424242424242, 0x4242424242424242}, v32_int8 = {0x42 <repeats 32 times>}, v16_int16 = {0x4242 <repeats 16 times>}, v8_int32 = {0x42424242, 0x42424242, 0x42424242, 0x42424242, 0x42424242, 0x42424242, 0x42424242, 0x42424242}, v4_int64 = {0x4242424242424242, 0x4242424242424242, 0x4242424242424242, 0x4242424242424242}, v2_int128 = {0x42424242424242424242424242424242, 0x42424242424242424242424242424242}}
(gdb) bt
#0  __memcpy_avx_unaligned_erms ()
    at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:271
#1  0x000056c2930d7edc in __interceptor_memcpy ()
#2  0x00007b221f1b443e in ??? ()
    at /lib/x86_64-linux-gnu/libgallium-25.2.8-0ubuntu0.24.04.1.so
#3  0x00007b221f20cc9b in ??? ()
    at /lib/x86_64-linux-gnu/libgallium-25.2.8-0ubuntu0.24.04.1.so
#4  0x00007b221f1b4d3a in ??? ()
    at /lib/x86_64-linux-gnu/libgallium-25.2.8-0ubuntu0.24.04.1.so
#5  0x00007b221f1b50d5 in ??? ()
    at /lib/x86_64-linux-gnu/libgallium-25.2.8-0ubuntu0.24.04.1.so
#6  0x00007b2241cca380 in readPixelsAllAtOnce ()
    at ../../third_party/angle/src/libANGLE/renderer/gl/FramebufferGL.cpp:1684
#7  0x00007b2241cc9442 in readPixels ()
    at ../../third_party/angle/src/libANGLE/renderer/gl/FramebufferGL.cpp:816
// ...

BISECTION

Introduced by ANGLE upstream commit [0] which added the CPU fallback implementation for CopyTextureCHROMIUM on OpenGL but failed to safely track buffer unbindings within the frontend state.

This regression was rolled into Chromium in commit [1].

[0] https://chromium.googlesource.com/angle/angle/+/aadc8f376a2c797db98d69d308c9980ca818f57f (Implement the CPU fallback for CopyTextureCHROMIUM on OpenGL)

[1] https://chromium.googlesource.com/chromium/src/+/236105a6afc95bfab50b0a3e3487c85cc049b49c (Roll ANGLE 79f7104..92996b0)

VERSION

Chrome Version: HEAD

Operating System: Linux

REPRODUCTION CASE

  1. Apply renderer.patch and build Chromium with ASan.
  2. Host the poc.html on an HTTP server.
  3. Run Chrome against the PoC.
$ python3 -m http.server 8000
$ ./out/asan/chrome "http://localhost:8000/poc.html"

CRASH INFORMATION

Type of crash: GPU process

Crash log: Attached asan_read.txt (Arbitrary Read) and asan_write.txt (Arbitrary Write).

CREDIT INFORMATION

Reporter credit: Anonymous

View on issue tracker