CVE-2026-9879
Overview
Background
- norm16 readback workaround
- Logic gated by
readPixelsUsingImplementationColorReadFormatForNorm16that rewritesGL_RED/GL_RGGL_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, thepixelsargument toglReadPixelsis a byte offset into that buffer, not a client memory pointer. - `clientPixels`
- ANGLE’s alias of the caller’s
pixelsused 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.
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
- Bind a PBO
Bind a buffer to
GL_PIXEL_PACK_BUFFERsoglReadPixels’spixelsargument is interpreted as an offset. - Select norm16 path
Issue a
GL_RED/GL_RGGL_UNSIGNED_SHORTreadback on hardware wherereadPixelsUsingImplementationColorReadFormatForNorm16is enabled. - Supply crafted offset
Pass a small integer such as
reinterpret_cast<void*>(8)aspixels, which the workaround takes asclientPixels. - 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
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
| Function | Change | Notes |
|---|---|---|
Texture2DRGTestsrc/tests/gl_tests/TextureTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/gl/FramebufferGL.cppsrc/tests/gl_tests/TextureTest.cpp
Audit Directions
- Pointer-vs-offset argument dualityAudit every path handling
glReadPixels/glTexImage/glGetTexImagepixelsand similar arguments to confirm thePixelPack/PixelUnpackbinding is checked before treating the value as a client pointer. - Workaround enable conditionsReview 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 pathsFuzz format/type/target-buffer combinations against hardware-specific workarounds, since defects here only manifest when a feature flag is active on particular GPUs.
Patch
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(),
Regression Test / PoC
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(),
Original Bug Report
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.
- Initialize a WebGL 2 context and enable the
EXT_texture_norm16extension. - Create an
R16_EXTtexture and attach it to a framebuffer. - Create a WebGL buffer and bind it to
gl.PIXEL_PACK_BUFFER. - 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. - Call
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_SHORT, TARGET_OFFSET), whereTARGET_OFFSETis the desired virtual address in the GPU process to be written to.
Suggested Fix
- Workaround Fix: Modify
FramebufferGL::readPixelsto prevent theEXT_texture_norm16workaround 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. - Error Handling Fix: Address the systemic issue in
BufferGL::setDatawhere native driver errors (like OOM) are silently ignored in release builds. TheANGLE_GL_TRYmacro 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.