Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in ANGLE
DescriptionInsufficient validation of untrusted input in ANGLE
ComponentANGLE
Bug ClassLogic Error
Tracker495373657
Fix commit3ff819cc07be (chromium/src) +108/-100
CISA KEVNot listed
Credited86ac1f1587b71893ed2ad792cd7dde32
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
gpu/command_buffer/service/gles2_cmd_decoder_passthrough_handlers.cc
modified

Files Changed

  • gpu/command_buffer/service/gles2_cmd_decoder_passthrough.h
  • gpu/command_buffer/service/gles2_cmd_decoder_passthrough_handlers.cc
From 3ff819cc07be9f8a36c8294494da9b57bd2ed857 Mon Sep 17 00:00:00 2001
From: Corentin Wallez <[email protected]>
Date: Tue, 31 Mar 2026 10:09:22 -0700
Subject: [PATCH] GLES2 passthrough decoder: validate unpack buffer is bound.

In the handling of gl[Compressed]Tex[Sub]Image[2D/3D] the
data_shm_offset can be used either as an offset in the
GL_PIXEL_UNPACK_BUFFER or as a pointer to memory to upload to the GPU.
When data_shm_id is 0 and the data_shm_offset is not 0.

When both id and offset are 0, it is allowed to have no unpack buffer
because all [Compressed]TexImage[2D/3D] use that to specify a
zero-initialized data store, and the [Compressed]TexSubImage[2D/3D] all
validate that nullptr is only used with empty sizes.

Bug: 495373657
Change-Id: I14f6d15889e0e5afffa45b4bc9198e76fe286939
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7702692
Reviewed-by: Geoff Lang <[email protected]>
Auto-Submit: Corentin Wallez <[email protected]>
Commit-Queue: Corentin Wallez <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1607944}
---

diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough.h b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough.h
index 4b895e8..136dd7e 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough.h
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough.h
@@ -449,6 +449,19 @@
 
   error::Error ProcessReadPixels(bool did_finish);
 
+  // Validates the use of shm_offset as either an offset in a shmem, or in the
+  // unpack buffer. When using unpack buffers, data will be a zero-length span
+  // with the address corresponding to the offset in the unpack buffer. (so its
+  // .data() and .size() are the arguments to pass to the TexImage family of
+  // functions, without additional changes needed).
+  // image_size is the corresponding argument passed to CompressedTex[Sub]Image
+  // functions so it can be validated against the shmem size when there is no
+  // unpack buffer.
+  error::Error ValidateAndGetTexImageData(base::span<const uint8_t>* data,
+                                          uint32_t shm_id,
+                                          uint32_t shm_offset,
+                                          uint32_t image_size = 0);
+
   // Checks to see if the inserted fence has completed.
   void ProcessDescheduleUntilFinished();
 
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_handlers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_handlers.cc
index 59b561a2..90d49da9 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_handlers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_passthrough_handlers.cc
@@ -10,6 +10,52 @@
 namespace gpu {
 namespace gles2 {
 
+error::Error GLES2DecoderPassthroughImpl::ValidateAndGetTexImageData(
+    base::span<const uint8_t>* data_out,
+    uint32_t shm_id,
+    uint32_t shm_offset,
+    uint32_t image_size) {
+  // When no unpack buffer is bound, only allow actual pointers to data, or
+  // nullptr (used to zero-initialize TexImage* and a GL error with non-empty
+  // uploads for TexSubImage).
+  if (bound_buffers_[GL_PIXEL_UNPACK_BUFFER] == 0) {
+    if (shm_id == 0) {
+      // data must be nullptr
+      if (image_size != 0) {
+        return error::kOutOfBounds;
+      }
+      if (shm_offset == 0) {
+        *data_out = {};
+        return error::kNoError;
+      }
+      return error::kInvalidArguments;
+    }
+
+    // data comes from shmem
+    unsigned int size = 0;
+    const uint8_t* data =
+        GetSharedMemoryAndSizeAs<const uint8_t*>(shm_id, shm_offset, 0, &size);
+    if (!data) {
+      return error::kOutOfBounds;
+    }
+    if (image_size > size) {
+      return error::kOutOfBounds;
+    }
+    *data_out = UNSAFE_TODO({data, size});
+    return error::kNoError;
+  }
+
+  // With an unpack buffer, no shmem can be used.
+  if (shm_id != 0) {
+    return error::kInvalidArguments;
+  }
+  // SAFETY: The span represents an offset in the unpack buffer, not actual
+  // memory. It's also of size 0.
+  *data_out = UNSAFE_BUFFERS(base::span<const uint8_t>(
+      reinterpret_cast<const uint8_t*>(static_cast<intptr_t>(shm_offset)), 0u));
+  return error::kNoError;
+}
+
 // Custom Handlers
 error::Error GLES2DecoderPassthroughImpl::HandleBindAttribLocationBucket(
     uint32_t immediate_data_size,
@@ -1046,22 +1092,15 @@
   uint32_t pixels_shm_id = c.pixels_shm_id;
   uint32_t pixels_shm_offset = c.pixels_shm_offset;
 
-  unsigned int buffer_size = 0;
-  const void* pixels = nullptr;
-
-  if (pixels_shm_id != 0) {
-    pixels = GetSharedMemoryAndSizeAs<uint8_t*>(
-        pixels_shm_id, pixels_shm_offset, 0, &buffer_size);
-    if (!pixels) {
-      return error::kOutOfBounds;
-    }
-  } else {
-    pixels =
-        reinterpret_cast<const void*>(static_cast<intptr_t>(pixels_shm_offset));
+  base::span<const uint8_t> pixels;
+  if (auto err =
+          ValidateAndGetTexImageData(&pixels, pixels_shm_id, pixels_shm_offset);
+      err != error::kNoError) {
+    return err;
   }
 
   return DoTexImage2D(target, level, internal_format, width, height, border,
-                      format, type, buffer_size, pixels);
+                      format, type, pixels.size(), pixels.data());
 }
 
 error::Error GLES2DecoderPassthroughImpl::HandleTexImage3D(
@@ -1084,22 +1123,15 @@
   uint32_t pixels_shm_id = c.pixels_shm_id;
   uint32_t pixels_shm_offset = c.pixels_shm_offset;
 
-  unsigned int buffer_size = 0;
-  const void* pixels = nullptr;
-
-  if (pixels_shm_id != 0) {
-    pixels = GetSharedMemoryAndSizeAs<uint8_t*>(
-        pixels_shm_id, pixels_shm_offset, 0, &buffer_size);
-    if (!pixels) {
-      return error::kOutOfBounds;
-    }
-  } else {
-    pixels =
-        reinterpret_cast<const void*>(static_cast<intptr_t>(pixels_shm_offset));
+  base::span<const uint8_t> pixels;
+  if (auto err =
+          ValidateAndGetTexImageData(&pixels, pixels_shm_id, pixels_shm_offset);
+      err != error::kNoError) {
+    return err;
   }
 
   return DoTexImage3D(target, level, internal_format, width, height, depth,
-                      border, format, type, buffer_size, pixels);
+                      border, format, type, pixels.size(), pixels.data());
 }
 
 error::Error GLES2DecoderPassthroughImpl::HandleTexSubImage2D(
@@ -1118,22 +1150,15 @@
   uint32_t pixels_shm_id = c.pixels_shm_id;
   uint32_t pixels_shm_offset = c.pixels_shm_offset;
 
-  unsigned int buffer_size = 0;
-  const void* pixels = nullptr;
-
-  if (pixels_shm_id != 0) {
-    pixels = GetSharedMemoryAndSizeAs<uint8_t*>(
-        pixels_shm_id, pixels_shm_offset, 0, &buffer_size);
-    if (!pixels) {
-      return error::kOutOfBounds;
-    }
-  } else {
-    pixels =
-        reinterpret_cast<const void*>(static_cast<intptr_t>(pixels_shm_offset));
+  base::span<const uint8_t> pixels;
+  if (auto err =
+          ValidateAndGetTexImageData(&pixels, pixels_shm_id, pixels_shm_offset);
+      err != error::kNoError) {
+    return err;
   }
 
   return DoTexSubImage2D(target, level, xoffset, yoffset, width, height, format,
-                         type, buffer_size, pixels);
+                         type, pixels.size(), pixels.data());
 }
 
 error::Error GLES2DecoderPassthroughImpl::HandleTexSubImage3D(
@@ -1157,22 +1182,16 @@
   uint32_t pixels_shm_id = c.pixels_shm_id;
   uint32_t pixels_shm_offset = c.pixels_shm_offset;
 
-  unsigned int buffer_size = 0;
-  const void* pixels = nullptr;
-
-  if (pixels_shm_id != 0) {
Loading diff…

Original Bug Report

reported by [email protected]

GPU process arbitrary address read via unvalidated client pointer in passthrough `CompressedTexImage3D` / `CompressedTexSubImage3D` handlers

Summary

The passthrough command decoder’s handlers for CompressedTexImage3D and CompressedTexSubImage3D reinterpret the command’s data_shm_offset field as a raw GPU-process pointer when data_shm_id is zero. This encoding is intended for Pixel Buffer Object offsets, but the handler performs no check that a PBO is actually bound. ANGLE’s compressed texture validators pass a hardcoded imageSize of negative one to the internal ValidImageDataSize helper, which then skips all host-memory size validation when no PBO is present. Every ANGLE backend dereferences this fabricated pointer during texture upload: D3D11 through Image11::loadCompressedData, Vulkan through TextureVk::stageSubresourceUpdate, and Metal through TextureMtl::setImageImpl. A compromised renderer can therefore read from any address in the low 4 GB of the GPU process address space by sending a single crafted command buffer entry. Platform: Windows, macOS, Linux, ChromeOS; any GPU.

Bisect

Introducing Commit: 95f938225bec04fe8e3d87bcc692f67c6388cf06

This commit added PBO offset support to the passthrough command decoder’s compressed texture upload handlers. The original code (from d00f0b244d1bb, 2016-06-03) treated all nonzero data_shm_id or data_shm_offset combinations as shared-memory lookups. The 95f938225bec0 change split the data_shm_id == 0 case into a new branch that reinterprets data_shm_offset as a raw pointer for PBO offsets, but omitted a check that a pixel unpack buffer is actually bound. The legacy (non-passthrough) command decoder contains the corresponding guard: it rejects data_shm_id == 0 when no PBO is bound by returning error::kInvalidArguments. The passthrough decoder never acquired an equivalent check.

Root Cause

The vulnerability arises from a mismatch between the command buffer’s data encoding convention and the validation performed downstream in ANGLE.

When the GPU-side passthrough handler receives a CompressedTexImage3D command, it branches on the data_shm_id field. A nonzero id triggers a validated shared-memory lookup with bounds checking. An id of zero, however, causes the handler to synthesize a pointer directly from data_shm_offset:

// gpu/command_buffer/service/gles2_cmd_decoder_passthrough_handlers.cc
const void* data = nullptr;
if (data_shm_id != 0) {
  unsigned int data_size = 0;
  data = GetSharedMemoryAndSizeAs<const void*>(data_shm_id, data_shm_offset,
                                               image_size, &data_size);
  if (data == nullptr) {
    return error::kOutOfBounds;
  }
} else {
  data =
      reinterpret_cast<const void*>(static_cast<intptr_t>(data_shm_offset));
}

The zero-id path exists to encode PBO offsets, where the data argument to glCompressedTexImage3D is treated as a byte offset into the currently bound pixel unpack buffer rather than a host pointer. The handler does not verify that a PBO is actually bound before constructing this pointer; it unconditionally forwards the fabricated address to DoCompressedTexImage3D, which passes it straight through to ANGLE:

// gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc
api()->glCompressedTexImage3DFn(target, level, internalformat, width, height,
                                depth, border, image_size, data);

Inside ANGLE, the ValidateCompressedTexImage3D function verifies format parameters and that imageSize matches the computed block size, then delegates to the shared ValidateES3TexImage3DParameters with a hardcoded imageSize argument of negative one:

// third_party/angle/src/libANGLE/validationES3.cpp
if (!ValidateES3TexImage3DParameters(context, entryPoint, target, level,
                                     internalformat, true, false, 0, 0, 0,
                                     width, height, depth, border, GL_NONE,
                                     GL_NONE, -1, data))

That negative-one value reaches ValidImageDataSize, which contains an early return that was designed to skip validation when the caller has already verified the compressed image size separately:

// third_party/angle/src/libANGLE/validationES.cpp
Buffer *pixelUnpackBuffer =
    context->getState().getTargetBuffer(BufferBinding::PixelUnpack);
if (pixelUnpackBuffer == nullptr && imageSize < 0)
{
    return true;
}

When no PBO is bound and imageSize is negative one, validation returns immediately without examining the data pointer at all. In standard OpenGL semantics a non-null data without a PBO is a valid client pointer upload, so ANGLE has no reason to reject it. The pointer proceeds through the D3D11 texture backend, where GetUnpackPointer returns it verbatim when no unpack buffer is present:

// third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp
if (unpackBuffer)
{
    // ... read from PBO ...
}
else
{
    *pointerOut = pixels;
}

The pointer finally reaches Image11::loadCompressedData, which maps a D3D11 staging texture and copies data from the pointer via a format-specific load function:

// third_party/angle/src/libANGLE/renderer/d3d/d3d11/Image11.cpp
loadFunction(context11->getImageLoadContext(), area.width, area.height,
             area.depth, static_cast<const uint8_t *>(input),
             inputRowPitch, inputDepthPitch, offsetMappedData,
             mappedImage.RowPitch, mappedImage.DepthPitch);

For a 4x4 DXT5 block this resolves to LoadCompressedToNative<4,4,1,16>, which performs a 16-byte memcpy from the attacker-supplied address into the staging texture. On a 64-bit build the fabricated pointer is zero-extended from 32 bits, restricting the read range to the low 4 GB of the GPU process virtual address space. This is sufficient to read mapped code sections and portions of static data, and the read content lands in a GPU texture that could in principle be exfiltrated back to the renderer via readPixels.

The passthrough decoder is the only command decoder compiled on non-Android platforms. The build system sets enable_validating_command_decoder = is_android in ui/gl/features.gni, so on Windows, macOS, Linux, and ChromeOS the passthrough path is always active and this vulnerability is always reachable.

The same data_shm_id == 0 pointer fabrication pattern appears in all four compressed texture upload handlers: HandleCompressedTexImage2D, HandleCompressedTexSubImage2D, HandleCompressedTexImage3D, and HandleCompressedTexSubImage3D.

Reproduce

The bug was tested on Chromium commit d633da0f560b561113ef431f228063b059b0c896 on macOS, Ubuntu22.04 and Windows 11.

This is a compromised-renderer attack against the GPU process. The renderer-side command buffer client is patched to bypass the normal bucket transfer path for compressed texture uploads and instead send a raw CompressedTexImage3D command with data_shm_id set to zero and data_shm_offset set to an arbitrary 32-bit address. The GPU process passthrough handler interprets this offset as a client pointer and forwards it to ANGLE, which dereferences it during texture upload. Apply the patch to the Chromium source tree from the repository root.

Configure an ASAN build with out/asan/args.gn as follows, then build:

is_asan = true
is_debug = false
dcheck_always_on = false
git apply patch.diff
autoninja -C out/asan chrome

Launch Chrome with the PoC page:

# Windows(poc.html uses an absolute path)
$ out\asan\chrome.exe --enable-logging=stderr --user-data-dir=/tmp/poc /path/to/poc.html 2>/tmp/asan.txt
$ cat /tmp/asan.txt

# Linux
out/asan/chrome --enable-logging=stderr --user-data-dir=./user poc.html

The GPU process will crash immediately when the WebGL2 page issues compressedTexImage3D. AddressSanitizer reports an access-violation READ at address 0x000041414141 inside angle::LoadCompressedToNative, called from rx::Image11::loadCompressedData. The full stack trace is preserved in asan.txt.

ASAN log:

=================================================================
==23904==ERROR: AddressSanitizer: access-violation on unknown address 0x000041414141 (pc 0x7ffb3f47dc26 bp 0x0026eedfd200 sp 0x0026eedfd178 T0)
==23904==The signal is caused by a READ memory access.
==23904==*** WARNING: Failed to initialize DbgHelp!              ***
==23904==*** Most likely this means that the app is already      ***
==23904==*** using DbgHelp, possibly with incompatible flags.    ***
==23904==*** Due to technical reasons, symbolization might crash ***
==23904==*** or produce wrong results.                           ***
    #0 0x7ffb3f47dc25 in memcpy+0x125 (C:\Windows\System32\ucrtbase.dll+0x1800edc25)
    #1 0x7ffac8ecb532 in _asan_memcpy+0x422 (D:\src\chromium\src\out\asan\clang_rt.asan_dynamic-x86_64.dll+0x18004b532)
    #2 0x7ffab7786e9c in angle::LoadCompressedToNative<4,4,1,16> D:\src\chromium\src\third_party\angle\src\image_util\loadimage.inc:402
    #3 0x7ffab7872070 in rx::Image11::loadCompressedData D:\src\chromium\src\third_party\angle\src\libANGLE\renderer\d3d\d3d11\Image11.cpp:352
    #4 0x7ffab79d0c73 in rx::TextureD3D_2DArray::setCompressedImage D:\src\chromium\src\third_party\angle\src\libANGLE\renderer\d3d\TextureD3D.cpp:3450
    #5 0x7ffab76a4f36 in gl::Texture::setCompressedImage D:\src\chromium\src\third_party\angle\src\libANGLE\Texture.cpp:1470
......

Credit

Please use 86ac1f1587b71893ed2ad792cd7dde32 as the credit for this vulnerability. Thank you.

View on issue tracker