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

Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in GPU
DescriptionOut of bounds write in GPU
ComponentGPU
Sub-componentInternals>GPU>Internals
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.
Tracker513165325
Fix commit509449efff48 (chromium/src) +107/-0
CWECWE-787 (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
CreditedGoogle
Reported2026-05-14 (per vendor advisory)
Fix landed2026-05-23 (public git)
Disclosed2026-06-02
Patch gap10 days — fix public in source before the advisory

Background

`glReadPixels` / `ReadPixels` command
the GLES2 call that copies a rectangular region of framebuffer pixels back into client memory, which in Chrome’s command buffer is a shared-memory transfer buffer between the renderer and the GPU process.
`GLES2Util::ElementsPerGroup`
a helper that returns the number of components per pixel for a given GL format enum (e.g. 3 for GL_RGB), returning 0 for any format it does not recognize.
`EXT_texture_norm16`
an extension adding 16-bit-per-component sized formats such as GL_R16_EXT, GL_RG16_EXT, GL_RGB16_EXT, and GL_RGBA16_EXT.
Validating command decoder
the GPU-process component that validates a renderer’s serialized GL commands and computes the expected byte size before handing buffers to the native driver.

Root Cause Analysis

Before the patch, GLES2Util::ElementsPerGroup had no case for the norm16 sized formats, so for a ReadPixels with format GL_R16_EXT it returned 0, and the downstream size computation (ComputeImageDataSizesES3, exercised by the test) produced a pixels_size of 0 bytes even though the read area was non-empty. The ReadPixels service handler then validated that 0-byte size against the transfer buffer via GetSharedMemoryAs, which happily accepted an offset at the very end of the buffer (one-past-end) since zero bytes “fit”. Because (GL_R16_EXT, GL_UNSIGNED_SHORT) was not in the decoder’s hardcoded accepted list, the code fell through to querying the native driver’s GL_IMPLEMENTATION_COLOR_READ_FORMAT/TYPE and then called the driver’s glReadPixels, which computed its own real size (width*height*2 bytes for R16) and wrote that many bytes into the pointer the decoder had validated for zero bytes — an out-of-bounds write past the transfer buffer in the GPU process.

The patch fixes this two ways: it adds the norm16 formats to ElementsPerGroup so the size is computed correctly, and it adds a defense-in-depth guard that rejects any ReadPixels where width > 0 && height > 0 && pixels_size == 0 with GL_INVALID_OPERATION before the driver call.

Key insight
A format enum that the size calculator did not recognize collapsed the computed transfer-buffer size to zero while the native driver still wrote the true width*height*bpp bytes; the fix teaches the calculator the norm16 formats and, as a backstop, refuses any non-empty read whose computed size is zero.

Attack Path

  1. Obtain a norm16-capable context A renderer (compromised or via crafted WebGL content) creates a GLES3 context with GL_EXT_texture_norm16 available, on a platform whose driver accepts a sized norm16 enum as the implementation color-read format.
  2. Craft a hostile ReadPixels The renderer issues a ReadPixels command with format GL_R16_EXT and type GL_UNSIGNED_SHORT and a non-zero width/height, placing the pixels shared-memory offset at the end of the transfer buffer.
  3. Bypass size validation ElementsPerGroup returns 0 for the unrecognized format, so pixels_size computes as 0; GetSharedMemoryAs validates zero bytes and returns a one-past-end pointer that passes the check.
  4. Driver writes out of bounds The decoder falls through to the native glReadPixels, which writes width*height*2 bytes starting at that one-past-end pointer, overflowing the transfer buffer in the GPU process (demonstrated by the ASAN-poisoned regression test).

Impact Assessment

An attacker controlling the renderer’s command stream gains an out-of-bounds heap write in the GPU process, past the end of a shared-memory transfer buffer, with the overflow length driven by the chosen width/height. The advisory classifies this as an out-of-bounds write in GPU; it does not report exploitation in the wild, and any escalation to further memory corruption or sandbox compromise is (inferred). Preconditions are a context exposing EXT_texture_norm16 and a native driver that reports a norm16 sized enum for the implementation color-read format.

Changed Functions

FunctionChangeNotes
if
gpu/command_buffer/service/gles2_cmd_decoder.cc
modified
TEST_P
gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
modified

Files Changed

  • gpu/command_buffer/common/gles2_cmd_utils.cc
  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc

Audit Directions

  • Format/type enum coverage
    Audit every switch over GL format/type enums (ElementsPerGroup, BytesPerElement, and similar) for a default that returns 0, and confirm no size calculation feeds a buffer bound where zero silently means “always valid”.
  • Zero-size validation passes
    Look for spots where a computed size of 0 bytes is treated as a successful bounds check (GetSharedMemoryAs, mapped-buffer sizing) while a downstream native call computes and writes its own, larger size.
  • Decoder/driver size disagreement
    Review paths that fall through to GL_IMPLEMENTATION_COLOR_READ_FORMAT/TYPE or otherwise let the native driver decide the transfer size, and verify the decoder-validated size cannot be smaller than what the driver actually writes.
From 509449efff48b83dec84d28eb2430ac083dd7af0 Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Fri, 22 May 2026 23:15:02 -0700
Subject: [PATCH] Properly handle EXT_texture_norm16 formats in ReadPixels.

Add defense-in-depth by ensuring the computed size for pixel formats
is non-zero, and that if ReadPixels is called with a non-zero area,
the resulting size is non-zero.

Added unit test from the bug report.

Co-authored by jetski-cli.

Fixed: 513165325
Change-Id: I4fb6e8e37b7286d328b3f79798311da24e805f06
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7872324
Commit-Queue: Kenneth Russell <kbr@chromium.org>
Reviewed-by: Kai Ninomiya <kainino@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1635378}
---

diff --git a/gpu/command_buffer/common/gles2_cmd_utils.cc b/gpu/command_buffer/common/gles2_cmd_utils.cc
index fc0aff1..f7583385 100644
--- a/gpu/command_buffer/common/gles2_cmd_utils.cc
+++ b/gpu/command_buffer/common/gles2_cmd_utils.cc
@@ -569,15 +569,18 @@
     case GL_RGB:
     case GL_RGB_INTEGER:
     case GL_SRGB_EXT:
+    case GL_RGB16_EXT:
       return 3;
     case GL_LUMINANCE_ALPHA:
     case GL_RG_EXT:
     case GL_RG_INTEGER:
+    case GL_RG16_EXT:
       return 2;
     case GL_RGBA:
     case GL_RGBA_INTEGER:
     case GL_BGRA_EXT:
     case GL_SRGB_ALPHA_EXT:
+    case GL_RGBA16_EXT:
       return 4;
     case GL_ALPHA:
     case GL_LUMINANCE:
@@ -589,6 +592,7 @@
     case GL_DEPTH_STENCIL_OES:
     case GL_RED_EXT:
     case GL_RED_INTEGER:
+    case GL_R16_EXT:
       return 1;
     default:
       return 0;
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder.cc b/gpu/command_buffer/service/gles2_cmd_decoder.cc
index c079b3b..1c5abf4d 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder.cc
@@ -11279,6 +11279,18 @@
     return error::kOutOfBounds;
   }
 
+  // Defense-in-depth: read_pixel_format may accept enums (e.g. sized norm16
+  // internal formats) that GLES2Util::ElementsPerGroup does not recognize, in
+  // which case ComputeImageDataSizesES3 succeeds with a 0-byte size. If we
+  // proceeded, GetSharedMemoryAs would validate 0 bytes and we would hand the
+  // pointer to the native driver, which writes width*height*bpp bytes -> OOB
+  // write past the renderer's transfer buffer in the GPU process.
+  if (width > 0 && height > 0 && pixels_size == 0) {
+    LOCAL_SET_GL_ERROR(GL_INVALID_OPERATION, func_name,
+                       "unsupported format/type combination");
+    return error::kNoError;
+  }
+
   uint8_t* pixels = nullptr;
   Buffer* buffer = state_.bound_pixel_pack_buffer.get();
   if (pixels_shm_id == 0) {
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
index d9f96879..b39cc0e 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
@@ -14,6 +14,9 @@
 #include "base/containers/heap_array.h"
 #include "base/memory/raw_ptr.h"
 #include "base/strings/string_number_conversions.h"
+#if defined(ADDRESS_SANITIZER)
+#include <sanitizer/asan_interface.h>
+#endif
 #include "gpu/command_buffer/common/gles2_cmd_format.h"
 #include "gpu/command_buffer/common/gles2_cmd_utils.h"
 #include "gpu/command_buffer/service/context_group.h"
@@ -26,6 +29,7 @@
 #include "gpu/command_buffer/service/program_manager.h"
 #include "gpu/command_buffer/service/test_helper.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/abseil-cpp/absl/cleanup/cleanup.h"
 #include "ui/gl/gl_implementation.h"
 #include "ui/gl/gl_mock.h"
 #include "ui/gl/gl_surface_stub.h"
@@ -828,6 +832,93 @@
   EXPECT_EQ(GL_NO_ERROR, GetGLError());
 }
 
+TEST_P(GLES3DecoderManualInitTest, ReadPixelsR16ExtZeroSizeValidationOOB) {
+  InitState init;
+  init.extensions = "GL_EXT_texture_norm16";
+  init.gl_version = "OpenGL ES 3.0";
+  init.context_type = CONTEXT_TYPE_OPENGLES3;
+  InitDecoder(init);
+
+  // Step 1: directly demonstrate the validator/size-calculator desync.
+  uint32_t computed = 0xdeadbeef;
+  EXPECT_TRUE(GLES2Util::ComputeImageDataSizes(64, 64, 1, GL_R16_EXT,
+                                               GL_UNSIGNED_SHORT, 4, &computed,
+                                               nullptr, nullptr));
+  EXPECT_EQ(8192u, computed);
+
+  // Step 2: send a hostile ReadPixels that a compromised renderer could craft.
+  // pixels_shm_offset = end-of-buffer; the decoder validates 0 bytes so this
+  // passes and returns base + kSharedBufferSize (one-past-end).
+  surface_->SetSize(gfx::Size(INT_MAX, INT_MAX));
+  const GLsizei kWidth = 32;
+  const GLsizei kHeight = 32;
+  // Native R16 + UNSIGNED_SHORT = 2 bytes/pixel -> 32*32*2 = 2048 bytes
+  // written by the driver, all of it past the validated buffer end.
+  const uint32_t kPixelsOffset = kSharedBufferSize;  // one-past-end
+
+#if defined(ADDRESS_SANITIZER)
+  // The transfer buffer is mmap'd shared memory (no ASAN redzones). Poison the
+  // page slack past the logical buffer end so ASAN deterministically reports
+  // the OOB write instead of relying on whatever mapping happens to follow.
+  // kSharedBufferSize = 2048; the mapping is at least one 4096-byte page, so
+  // [base+2048, base+4096) is mapped slack we can safely poison.
+
+  // SAFETY: this is the only feasible way to get the pointer we need.
+  // This test is temporary regardless and will be removed along with
+  // the validating command decoder.
+  uint8_t* poison_at = UNSAFE_BUFFERS(
+      static_cast<uint8_t*>(shared_memory_base_.get()) + kSharedBufferSize);
+  static_assert(kSharedBufferSize == 2048);
+  const size_t kPoisonLen = 2048;
+  ASAN_POISON_MEMORY_REGION(poison_at, kPoisonLen);
+  absl::Cleanup unpoison = [poison_at] {
+    ASAN_UNPOISON_MEMORY_REGION(poison_at, kPoisonLen);
+  };
+#endif
+
+  // The decoder falls through to the native-driver IMPLEMENTATION_COLOR_READ_*
+  // query because (GL_R16_EXT, GL_UNSIGNED_SHORT) is not in the hardcoded
+  // accepted list. Simulate an Android native driver that returns the sized
+  // enum (the precondition documented in the report).
+  EXPECT_CALL(*gl_, GetIntegerv(GL_IMPLEMENTATION_COLOR_READ_FORMAT, _))
+      .Times(AnyNumber())
+      .WillRepeatedly(SetArgPointee<1>(GL_R16_EXT));
+  EXPECT_CALL(*gl_, GetIntegerv(GL_IMPLEMENTATION_COLOR_READ_TYPE, _))
+      .Times(AnyNumber())
+      .WillRepeatedly(SetArgPointee<1>(GL_UNSIGNED_SHORT));
+  EXPECT_CALL(*gl_, GetError())
+      .Times(AnyNumber())
+      .WillRepeatedly(Return(GL_NO_ERROR));
+
+  // The "native driver" computes its own write size for R16 + UNSIGNED_SHORT
+  // (2 bytes/pixel) and writes into the pointer the decoder validated for 0
+  // bytes. With kWidth*kHeight*2 = 8192 bytes starting at one-past-end of a
+  // 2048-byte transfer buffer, this is a hard OOB write past the shm mapping.
+  EXPECT_CALL(
+      *gl_, ReadPixels(0, 0, kWidth, kHeight, GL_R16_EXT, GL_UNSIGNED_SHORT, _))
+      .Times(AnyNumber())
+      .WillRepeatedly([](GLint, GLint, GLsizei w, GLsizei h, GLenum, GLenum,
+                         void* pixels) {
+        // What a native R16 glReadPixels does: write w*h*2 bytes.
+
+        // SAFETY: taking a shortcut to fill this memory since this
+        // test is temporary and will be removed soon along with the
+        // validating command decoder.
+        UNSAFE_BUFFERS(memset(pixels, 0xAB, static_cast<size_t>(w) * h * 2u));
+      });
+
+  cmds::ReadPixels cmd;
+  cmd.Init(0, 0, kWidth, kHeight, GL_R16_EXT, GL_UNSIGNED_SHORT,
+           shared_memory_id_, kPixelsOffset, /*result_shm_id=*/0,
+           /*result_shm_offset=*/0, /*async=*/false);
+  // OOB write fires inside this call: ASAN reports a 2048-byte write past the
+  // end of the transfer buffer (the region the decoder validated for 0 bytes).
+  // After the bandaid fix, the command is rejected with GL_INVALID_OPERATION
+  // before reaching glReadPixelsFn.
+  ExecuteCmd(cmd);
+  GetGLError();  // Consume the INVALID_OPERATION emitted by the fixed decoder.
+}
+
 TEST_P(GLES3DecoderTest, ReadPixelsPixelPackBufferIsNotLargeEnough) {
   const GLsizei kWidth = 5;
   const GLsizei kHeight = 3;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
index d9f96879..b39cc0e 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_framebuffers.cc
@@ -14,6 +14,9 @@
 #include "base/containers/heap_array.h"
 #include "base/memory/raw_ptr.h"
 #include "base/strings/string_number_conversions.h"
+#if defined(ADDRESS_SANITIZER)
+#include <sanitizer/asan_interface.h>
+#endif
 #include "gpu/command_buffer/common/gles2_cmd_format.h"
 #include "gpu/command_buffer/common/gles2_cmd_utils.h"
 #include "gpu/command_buffer/service/context_group.h"
@@ -26,6 +29,7 @@
 #include "gpu/command_buffer/service/program_manager.h"
 #include "gpu/command_buffer/service/test_helper.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/abseil-cpp/absl/cleanup/cleanup.h"
 #include "ui/gl/gl_implementation.h"
 #include "ui/gl/gl_mock.h"
 #include "ui/gl/gl_surface_stub.h"
@@ -828,6 +832,93 @@
   EXPECT_EQ(GL_NO_ERROR, GetGLError());
 }
 
+TEST_P(GLES3DecoderManualInitTest, ReadPixelsR16ExtZeroSizeValidationOOB) {
+  InitState init;
+  init.extensions = "GL_EXT_texture_norm16";
+  init.gl_version = "OpenGL ES 3.0";
+  init.context_type = CONTEXT_TYPE_OPENGLES3;
+  InitDecoder(init);
+
+  // Step 1: directly demonstrate the validator/size-calculator desync.
+  uint32_t computed = 0xdeadbeef;
+  EXPECT_TRUE(GLES2Util::ComputeImageDataSizes(64, 64, 1, GL_R16_EXT,
+                                               GL_UNSIGNED_SHORT, 4, &computed,
+                                               nullptr, nullptr));
+  EXPECT_EQ(8192u, computed);
+
+  // Step 2: send a hostile ReadPixels that a compromised renderer could craft.
+  // pixels_shm_offset = end-of-buffer; the decoder validates 0 bytes so this
+  // passes and returns base + kSharedBufferSize (one-past-end).
+  surface_->SetSize(gfx::Size(INT_MAX, INT_MAX));
+  const GLsizei kWidth = 32;
+  const GLsizei kHeight = 32;
+  // Native R16 + UNSIGNED_SHORT = 2 bytes/pixel -> 32*32*2 = 2048 bytes
+  // written by the driver, all of it past the validated buffer end.
+  const uint32_t kPixelsOffset = kSharedBufferSize;  // one-past-end
+
+#if defined(ADDRESS_SANITIZER)
+  // The transfer buffer is mmap'd shared memory (no ASAN redzones). Poison the
+  // page slack past the logical buffer end so ASAN deterministically reports
+  // the OOB write instead of relying on whatever mapping happens to follow.
+  // kSharedBufferSize = 2048; the mapping is at least one 4096-byte page, so
+  // [base+2048, base+4096) is mapped slack we can safely poison.
+
+  // SAFETY: this is the only feasible way to get the pointer we need.
+  // This test is temporary regardless and will be removed along with
+  // the validating command decoder.
+  uint8_t* poison_at = UNSAFE_BUFFERS(
+      static_cast<uint8_t*>(shared_memory_base_.get()) + kSharedBufferSize);
+  static_assert(kSharedBufferSize == 2048);
+  const size_t kPoisonLen = 2048;
+  ASAN_POISON_MEMORY_REGION(poison_at, kPoisonLen);
+  absl::Cleanup unpoison = [poison_at] {
+    ASAN_UNPOISON_MEMORY_REGION(poison_at, kPoisonLen);
+  };
+#endif
+
+  // The decoder falls through to the native-driver IMPLEMENTATION_COLOR_READ_*
+  // query because (GL_R16_EXT, GL_UNSIGNED_SHORT) is not in the hardcoded
+  // accepted list. Simulate an Android native driver that returns the sized
+  // enum (the precondition documented in the report).
+  EXPECT_CALL(*gl_, GetIntegerv(GL_IMPLEMENTATION_COLOR_READ_FORMAT, _))
+      .Times(AnyNumber())
+      .WillRepeatedly(SetArgPointee<1>(GL_R16_EXT));
+  EXPECT_CALL(*gl_, GetIntegerv(GL_IMPLEMENTATION_COLOR_READ_TYPE, _))
+      .Times(AnyNumber())
+      .WillRepeatedly(SetArgPointee<1>(GL_UNSIGNED_SHORT));
+  EXPECT_CALL(*gl_, GetError())
+      .Times(AnyNumber())
+      .WillRepeatedly(Return(GL_NO_ERROR));
+
+  // The "native driver" computes its own write size for R16 + UNSIGNED_SHORT
+  // (2 bytes/pixel) and writes into the pointer the decoder validated for 0
+  // bytes. With kWidth*kHeight*2 = 8192 bytes starting at one-past-end of a
+  // 2048-byte transfer buffer, this is a hard OOB write past the shm mapping.
+  EXPECT_CALL(
+      *gl_, ReadPixels(0, 0, kWidth, kHeight, GL_R16_EXT, GL_UNSIGNED_SHORT, _))
+      .Times(AnyNumber())
+      .WillRepeatedly([](GLint, GLint, GLsizei w, GLsizei h, GLenum, GLenum,
+                         void* pixels) {
+        // What a native R16 glReadPixels does: write w*h*2 bytes.
+
+        // SAFETY: taking a shortcut to fill this memory since this
+        // test is temporary and will be removed soon along with the
+        // validating command decoder.
+        UNSAFE_BUFFERS(memset(pixels, 0xAB, static_cast<size_t>(w) * h * 2u));
+      });
+
+  cmds::ReadPixels cmd;
+  cmd.Init(0, 0, kWidth, kHeight, GL_R16_EXT, GL_UNSIGNED_SHORT,
+           shared_memory_id_, kPixelsOffset, /*result_shm_id=*/0,
+           /*result_shm_offset=*/0, /*async=*/false);
+  // OOB write fires inside this call: ASAN reports a 2048-byte write past the
+  // end of the transfer buffer (the region the decoder validated for 0 bytes).
+  // After the bandaid fix, the command is rejected with GL_INVALID_OPERATION
+  // before reaching glReadPixelsFn.
+  ExecuteCmd(cmd);
+  GetGLError();  // Consume the INVALID_OPERATION emitted by the fixed decoder.
+}
+
 TEST_P(GLES3DecoderTest, ReadPixelsPixelPackBufferIsNotLargeEnough) {
   const GLsizei kWidth = 5;
   const GLsizei kHeight = 3;
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential OOB write in GPU process via GLES2 ReadPixels validator desync

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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A desync between the GLES2 command validator and its size calculation logic allows certain sized internal formats to pass validation with a calculated buffer size of zero. This results in an out-of-bounds write when the native GPU driver executes glReadPixels into a shared memory buffer that was only validated for zero bytes.

Affected files:

  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/common/gles2_cmd_utils.cc
  • gpu/command_buffer/service/feature_info.cc

Estimated timestamp from git blame: 2020-01-30

Summary

A potential out-of-bounds (OOB) write vulnerability exists in the GLES2 validating command decoder’s handling of glReadPixels. The issue arises from a discrepancy between the set of formats permitted by the command validator and the formats supported by the size calculation utility. When certain sized internal formats (e.g., GL_R16_EXT) are used, the decoder incorrectly validates a zero-byte destination buffer, while the underlying native driver performs a full-sized write, leading to memory corruption in the GPU process.

Root Cause Analysis

The vulnerability is caused by a logic desync between gpu::gles2::FeatureInfo and gpu::GLES2Util.

In gpu/command_buffer/service/feature_info.cc, if the GL_EXT_texture_norm16 extension is supported, several sized internal formats are added to the read_pixel_format validator (lines 1412-1414):

validators_.read_pixel_format.AddValue(GL_R16_EXT);
validators_.read_pixel_format.AddValue(GL_RG16_EXT);
validators_.read_pixel_format.AddValue(GL_RGBA16_EXT);

However, GLES2Util::ElementsPerGroup in gpu/command_buffer/common/gles2_cmd_utils.cc (lines 553-596) does not recognize these sized formats. In the switch (format) block, these enums are missing, causing the function to hit the default case and return 0.

Consequently:

  1. ComputeImageGroupSize returns 0 (bytes_per_element * 0).
  2. ComputeImageDataSizesES3 (called by the decoder) calculates a pixels_size of 0 for these formats.
  3. In GLES2DecoderImpl::HandleReadPixels (gpu/command_buffer/service/gles2_cmd_decoder.cc), the call to GetSharedMemoryAs<uint8_t*>(pixels_shm_id, pixels_shm_offset, pixels_size) uses this zero size.
  4. The shared memory validation in gpu/command_buffer/common/buffer.cc (line 101) passes for any valid offset because the requested size is 0.

Potential Vulnerability Path

The following steps outline how an attacker might potentially trigger this issue (note: these steps are theoretical as our tooling cannot currently execute code):

  1. A compromised renderer process establishes a GLES2 context on an Android device where GL_EXT_texture_norm16 is supported.
  2. The renderer issues a ReadPixels command using an affected sized format (e.g., GL_R16_EXT).
  3. The decoder incorrectly calculates pixels_size = 0 due to the root cause mentioned above.
  4. GetSharedMemoryAs validates the shared memory region for 0 bytes; this check passes for an offset at the end of a valid shared memory mapping.
  5. The read_pixel_format validator identifies the format as valid because it was explicitly whitelisted by FeatureInfo.
  6. The command falls through to the native glReadPixels call. If the driver supports this format for the current framebuffer, it executes the read and writes the actual pixel data (e.g., width * height * 2 bytes for GL_R16_EXT) to the provided pointer.
  7. This results in an out-of-bounds write into adjacent GPU process memory.

Impact

This issue allows an out-of-bounds write in the GPU process. On Android, the GPU process is typically unsandboxed (not using isolatedProcess by default). An attacker who has already compromised a renderer can potentially use this vulnerability to escalate privileges to the GPU process, leading to a full device compromise.

Suggested Fix

Update gpu/command_buffer/common/gles2_cmd_utils.cc to include the missing sized formats in GLES2Util::ElementsPerGroup:

    case GL_R16_EXT:
      return 1;
    case GL_RG16_EXT:
      return 2;
    case GL_RGBA16_EXT:
      return 4;

Additionally, ComputeImageGroupSize should likely CHECK that the result of ElementsPerGroup is non-zero if the format itself is not zero.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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