Critical watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

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
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker499129768
Fix commitc466bb31a412 (angle/angle) +29/-2
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
CreditedGoogle
Reported2026-04-03 (per vendor advisory)
Fix landed2026-04-22 (public git)
Disclosed2026-05-27
Patch gap35 days — fix public in source before the advisory

Background

norm16 readback workaround
Logic gated by readPixelsUsingImplementationColorReadFormatForNorm16 that rewrites GL_RED/GL_RG GL_UNSIGNED_SHORT (norm16) reads to a supported format and post-processes the result on a limited set of GPUs.
`PIXEL_PACK_BUFFER` (PBO)
A buffer object bound to gl::BufferBinding::PixelPack; when bound, the pixels argument to glReadPixels is a byte offset into that buffer, not a client memory pointer.
`clientPixels`
ANGLE’s alias of the caller’s pixels used by the workaround as a CPU-side destination for post-processed readback data.

Root Cause Analysis

FramebufferGL::readPixels enabled the norm16 workaround based only on type, originalReadFormat, and format, without checking whether a PIXEL_PACK_BUFFER was bound. When the workaround engages it assigns clientPixels = pixels and writes post-processed readback bytes through that pointer, assuming pixels addresses client-side memory. However, per the GL specification, when a buffer is bound to gl::BufferBinding::PixelPack, pixels is instead a numeric byte offset into that PBO. The workaround therefore treated an integer offset (e.g. 8) as a raw client pointer and wrote through it, producing an out-of-bounds write to an attacker-influenced low address or past the intended client buffer.

The fix adds context->getState().getTargetBuffer(gl::BufferBinding::PixelPack) == nullptr to the enable condition, forcing PBO readback down the normal, offset-aware path.

Key insight
The pixels argument to glReadPixels changes meaning — client pointer versus PBO byte offset — depending on PIXEL_PACK_BUFFER binding state, and the workaround unconditionally dereferenced it as a pointer.

Attack Path

  1. Bind a PBO Bind a buffer to GL_PIXEL_PACK_BUFFER so glReadPixels’s pixels argument is interpreted as an offset.
  2. Select norm16 path Issue a GL_RED/GL_RG GL_UNSIGNED_SHORT readback on hardware where readPixelsUsingImplementationColorReadFormatForNorm16 is enabled.
  3. Supply crafted offset Pass a small integer such as reinterpret_cast<void*>(8) as pixels, which the workaround takes as clientPixels.
  4. Trigger OOB write ANGLE writes post-processed readback bytes through the offset-as-pointer, corrupting memory at the low or out-of-range address.

Impact Assessment

This is a controlled out-of-bounds write in the GPU process reachable through the standard WebGL/WebGL2 readPixels API, requiring only a bound PIXEL_PACK_BUFFER and a norm16 read on affected GPUs. Because the write destination derives from the attacker-supplied offset and the copied readback size, it can corrupt process memory and serve as a primitive toward further exploitation, though it is gated by the workaround being active on specific hardware.

Changed Functions

FunctionChangeNotes
Texture2DRGTest
src/tests/gl_tests/TextureTest.cpp
modified

Files Changed

  • src/libANGLE/renderer/gl/FramebufferGL.cpp
  • src/tests/gl_tests/TextureTest.cpp

Audit Directions

  • Pointer-vs-offset argument duality
    Audit every path handling glReadPixels/glTexImage/glGetTexImage pixels and similar arguments to confirm the PixelPack/PixelUnpack binding is checked before treating the value as a client pointer.
  • Workaround enable conditions
    Review other features.* workarounds that alias caller pointers (e.g. clientPixels = pixels) to ensure their enable gates account for buffer-bound state, not just format/type.
  • GPU-conditional code paths
    Fuzz format/type/target-buffer combinations against hardware-specific workarounds, since defects here only manifest when a feature flag is active on particular GPUs.
From c466bb31a41235ec702303d63464964af12e9619 Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Tue, 21 Apr 2026 15:12:00 -0700
Subject: [PATCH] Disable norm16 readback workaround if PBO is bound.

This workaround was required only on a limited set of GPUs and doesn't
work if the destination is a PBO - only client side memory.

Verified that the new test catches the bug in an ASAN build on Linux.

Fixed: chromium:499129768
Change-Id: Ib2a5fd77ea62de600ebc263791c722420e5e958d
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7783582
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Auto-Submit: Kenneth Russell <kbr@chromium.org>
Commit-Queue: Geoff Lang <geofflang@chromium.org>
---

diff --git a/src/libANGLE/renderer/gl/FramebufferGL.cpp b/src/libANGLE/renderer/gl/FramebufferGL.cpp
index ecea51f..6889e4c 100644
--- a/src/libANGLE/renderer/gl/FramebufferGL.cpp
+++ b/src/libANGLE/renderer/gl/FramebufferGL.cpp
@@ -311,9 +311,11 @@
         ContextGL *contextGL              = GetImplAs<ContextGL>(context);
         const angle::FeaturesGL &features = GetFeaturesGL(context);
 
+        // This workaround does not work if the destination is a Pixel Buffer Object.
         enabled = features.readPixelsUsingImplementationColorReadFormatForNorm16.enabled &&
                   type == GL_UNSIGNED_SHORT && originalReadFormat == GL_RGBA &&
-                  (format == GL_RED || format == GL_RG);
+                  (format == GL_RED || format == GL_RG) &&
+                  context->getState().getTargetBuffer(gl::BufferBinding::PixelPack) == nullptr;
 
         clientPixels = pixels;
 
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 83201fc..d8a1736 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -12317,6 +12317,28 @@
     testNorm16RenderAndReadPixels(GL_RGBA16_EXT, GL_RGBA, GL_UNSIGNED_SHORT);
 }
 
+// Test that readback with PBO correctly uses the offset, and doesn't dereference it as a pointer.
+TEST_P(Texture2DNorm16TestES3, ReadbackWithPBO)
+{
+    ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_texture_norm16"));
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_R16_EXT, 1, 1);
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
+    EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    GLBuffer pbo;
+    glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
+    glBufferData(GL_PIXEL_PACK_BUFFER, 16, nullptr, GL_STREAM_READ);
+
+    glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_SHORT, reinterpret_cast<void *>(8));
+    EXPECT_GL_NO_ERROR();
+}
+
 class Texture2DRGTest : public Texture2DTest
 {
   protected:
@@ -19618,7 +19640,10 @@
 ANGLE_INSTANTIATE_TEST_ES2(TextureLimitsTest);
 
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(Texture2DNorm16TestES3);
-ANGLE_INSTANTIATE_TEST_ES3(Texture2DNorm16TestES3);
+ANGLE_INSTANTIATE_TEST_ES3_AND(
+    Texture2DNorm16TestES3,
+    ES3_OPENGL().enable(Feature::ReadPixelsUsingImplementationColorReadFormatForNorm16),
+    ES3_OPENGLES().enable(Feature::ReadPixelsUsingImplementationColorReadFormatForNorm16));
 
 ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND(Texture2DRGTest,
                                        ES2_EMULATE_COPY_TEX_IMAGE_VIA_SUB(),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 83201fc..d8a1736 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -12317,6 +12317,28 @@
     testNorm16RenderAndReadPixels(GL_RGBA16_EXT, GL_RGBA, GL_UNSIGNED_SHORT);
 }
 
+// Test that readback with PBO correctly uses the offset, and doesn't dereference it as a pointer.
+TEST_P(Texture2DNorm16TestES3, ReadbackWithPBO)
+{
+    ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_texture_norm16"));
+
+    GLTexture tex;
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_R16_EXT, 1, 1);
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
+    EXPECT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    GLBuffer pbo;
+    glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
+    glBufferData(GL_PIXEL_PACK_BUFFER, 16, nullptr, GL_STREAM_READ);
+
+    glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_SHORT, reinterpret_cast<void *>(8));
+    EXPECT_GL_NO_ERROR();
+}
+
 class Texture2DRGTest : public Texture2DTest
 {
   protected:
@@ -19618,7 +19640,10 @@
 ANGLE_INSTANTIATE_TEST_ES2(TextureLimitsTest);
 
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(Texture2DNorm16TestES3);
-ANGLE_INSTANTIATE_TEST_ES3(Texture2DNorm16TestES3);
+ANGLE_INSTANTIATE_TEST_ES3_AND(
+    Texture2DNorm16TestES3,
+    ES3_OPENGL().enable(Feature::ReadPixelsUsingImplementationColorReadFormatForNorm16),
+    ES3_OPENGLES().enable(Feature::ReadPixelsUsingImplementationColorReadFormatForNorm16));
 
 ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND(Texture2DRGTest,
                                        ES2_EMULATE_COPY_TEX_IMAGE_VIA_SUB(),
Loading diff…

Original Bug Report

reported by vm...@google.com

Arbitrary Write in ANGLE via EXT_texture_norm16 Readback Workaround

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: An out-of-bounds write vulnerability exists in ANGLE’s OpenGL backend when using the EXT_texture_norm16 readback workaround with a Pixel Buffer Object (PBO). The workaround misinterprets an attacker-controlled PBO offset as a host pointer and writes to it directly, potentially leading to an arbitrary write primitive and RCE in the GPU process.

Affected files:

  • third_party/angle/src/libANGLE/renderer/gl/FramebufferGL.cpp

Estimated timestamp from git blame: 2020-01-03

Description

A vulnerability exists in ANGLE’s OpenGL backend implementation of the EXT_texture_norm16 readback workaround, located in src/libANGLE/renderer/gl/FramebufferGL.cpp. This workaround is designed to handle glReadPixels calls using the RGBA/UNSIGNED_SHORT format/type from textures with R16_EXT or RG16_EXT formats, a combination that some native drivers handle incorrectly.

The workaround implementation, specifically RearrangeEXTTextureNorm16Pixels, fails to account for cases where a Pixel Buffer Object (PBO) is bound. When a PBO is bound, the pixels parameter to glReadPixels represents a byte offset into the PBO, not a host memory address. However, the workaround casts this offset to a CPU pointer and writes pixel data directly to it, resulting in a “wild write” at a controlled virtual address.

Bypass Mechanism and Trigger

To exploit this, an attacker must provide a high offset (e.g., 0x70000000) to glReadPixels. Both Blink and ANGLE validate that offset + read_size <= PBO_size. However, an attacker can bypass these checks by requesting a massive buffer allocation (e.g., 2GB) using gl.bufferData().

On constrained systems (like 32-bit Android devices), the native OpenGL driver will likely fail to allocate 2GB and return a GL_OUT_OF_MEMORY error. Crucially, in release builds of ANGLE, the ANGLE_GL_TRY macro is a no-op, meaning BufferGL::setData ignores the native driver’s OOM error and reports success to the frontend.

Consequently, both Blink and ANGLE’s frontend state incorrectly record the buffer size as 2GB. When the attacker subsequently calls glReadPixels with a high offset, the validation checks pass. The native driver will fail the read operation (because the actual buffer is non-existent), but ANGLE_GL_TRY ignores this error as well. Execution then proceeds to RearrangeEXTTextureNorm16Pixels, which writes to the attacker-controlled offset.

Impact

The write pattern is a fixed 8-byte sequence per pixel: 00 00 00 00 00 00 FF FF. On 32-bit GPU process configurations (common on Android Mali/Adreno devices where this workaround is enabled), this allows a genuine fixed-pattern write primitive against predictable heap structures or executable memory, potentially leading to full Remote Code Execution (RCE) in the GPU process.

Potential Reproduction Steps

Note: These are theoretical steps based on code analysis.

  1. Initialize a WebGL 2 context and enable the EXT_texture_norm16 extension.
  2. Create an R16_EXT texture and attach it to a framebuffer.
  3. Create a WebGL buffer and bind it to gl.PIXEL_PACK_BUFFER.
  4. Call gl.bufferData(gl.PIXEL_PACK_BUFFER, 2000000000, gl.STATIC_DRAW) to request a massive buffer, intentionally causing a native driver OOM that ANGLE will ignore.
  5. Call gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_SHORT, TARGET_OFFSET), where TARGET_OFFSET is the desired virtual address in the GPU process to be written to.

Suggested Fix

  1. Workaround Fix: Modify FramebufferGL::readPixels to prevent the EXT_texture_norm16 workaround from executing when a PBO is bound, or update the workaround to properly handle PBOs by performing the pixel expansion via native OpenGL commands (e.g., using a compute shader or intermediate buffer object) rather than CPU-side pointer manipulation.
  2. Error Handling Fix: Address the systemic issue in BufferGL::setData where native driver errors (like OOM) are silently ignored in release builds. The ANGLE_GL_TRY macro or the error handling logic in the backend should ensure critical failures are propagated to the frontend so that internal buffer size tracking remains accurate.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker