Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebGL
DescriptionUse after free in WebGL
ComponentWebGL
Bug ClassUAF
Tracker492228019
Fix commit7500f1d78b8c (chromium/src) +337/-7
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-31

Changed Functions

FunctionChangeNotes
if
gpu/command_buffer/client/gles2_implementation.cc
modified
for
gpu/command_buffer/client/gles2_implementation.cc
modified
TEST_F
gpu/command_buffer/client/gles2_implementation_unittest.cc
modified

Files Changed

  • gpu/BUILD.gn
  • gpu/command_buffer/client/gles2_implementation.cc
  • gpu/command_buffer/client/gles2_implementation_unittest.cc
From 7500f1d78b8c1e52dff6a3966e65c3eafa104d64 Mon Sep 17 00:00:00 2001
From: Gregg Tavares <[email protected]>
Date: Mon, 16 Mar 2026 18:04:27 -0700
Subject: [PATCH] Fix Use-After-Free with GL MapBufferRange with shadow buf

When MapBufferRange is called on a readback-usage buffer,
it returns a subspan of the shadow buffer allocation.
This subspan pointer (base + offset) was being passed to
FreePendingToken during UnmapBuffer, causing the FencedAllocator
to misidentify and erroneously free the next adjacent memory block.

This change ensures that shadow-mapped buffers are correctly
unmapped via UnmapReadbackShm, which uses the correct base pointer.
Added null checks for shadow buffer lookups to handle cases where a
buffer mapping is tracked but the shadow buffer has been destroyed.

Added regression tests:
- UnmapBufferWithOffsetFreesCorrectBlock: Verifies that unmapping a
  shadow buffer with an offset does not free adjacent blocks.
  This was crashing before the fix.
- ReadbackShadowMixedCleanup: Verifies that multiple shadow mappings
  with various offsets are correctly cleaned up.

Added coverage tests:
- ClearMappedBufferRangeMapShadow: Verifies ClearMappedBufferRangeMap
  correctly unmaps shadow readback buffers.
- ClearMappedBufferMap: Verifies ClearMappedBufferMap correctly frees
  transfer buffers mapped via MapBufferSubDataCHROMIUM.
- AllocateShadowCopiesForReadbackNullBuffer: Verifies that
  AllocateShadowCopiesForReadback safely skips buffers that were
  deleted before being fenced, preventing a null pointer dereference.
- AllocateShadowCopiesForReadbackAllocFail: Verifies that
  AllocateShadowCopiesForReadback handles memory allocation failures
  gracefully by skipping the shadow allocation instead of issuing a
  command with an invalid shared memory ID.
- AllocateShadowCopiesForReadbackAlreadyAllocated: Verifies that
  AllocateShadowCopiesForReadback issues a performance warning if a
  READ-usage buffer is written to multiple times before being fenced.
- ReadbackBufferShadowTrackerTest.AllocFails: Verifies that the shadow
  tracker correctly handles internal allocation failures from
  MappedMemoryManager.

Bug: 492228019
Change-Id: I8e12ffd1ed4356609e7aeeef1552e81e250ae7bd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7667030
Reviewed-by: Kenneth Russell <[email protected]>
Commit-Queue: Gregg Tavares <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1600244}
---

diff --git a/gpu/BUILD.gn b/gpu/BUILD.gn
index e181de63..2f916f0 100644
--- a/gpu/BUILD.gn
+++ b/gpu/BUILD.gn
@@ -414,6 +414,7 @@
     "command_buffer/client/query_tracker_unittest.cc",
     "command_buffer/client/raster_implementation_unittest.cc",
     "command_buffer/client/raster_implementation_unittest_autogen.h",
+    "command_buffer/client/readback_buffer_shadow_tracker_unittest.cc",
     "command_buffer/client/ring_buffer_test.cc",
     "command_buffer/client/shared_image_pool_unittest.cc",
     "command_buffer/client/transfer_buffer_cmd_copy_helpers_unittest.cc",
diff --git a/gpu/command_buffer/client/gles2_implementation.cc b/gpu/command_buffer/client/gles2_implementation.cc
index 019e750b..8c2c404 100644
--- a/gpu/command_buffer/client/gles2_implementation.cc
+++ b/gpu/command_buffer/client/gles2_implementation.cc
@@ -5603,8 +5603,19 @@
     auto iter = mapped_buffer_range_map_.find(buffer);
     if (iter != mapped_buffer_range_map_.end() &&
         !iter->second.shm_memory.empty()) {
-      mapped_memory_->FreePendingToken(iter->second.shm_memory.data(),
-                                       helper_->InsertToken());
+      if (iter->second.shm_id != 0) {
+        // This was a normal transfer buffer allocation.
+        mapped_memory_->FreePendingToken(iter->second.shm_memory.data(),
+                                         helper_->InsertToken());
+      } else {
+        // This was a shadow copy for readback. It's owned by the
+        // readback_buffer_shadow_tracker_, so we just need to unmap it.
+        auto* shadow_buffer =
+            readback_buffer_shadow_tracker_->GetBuffer(iter->first);
+        if (shadow_buffer) {
+          shadow_buffer->UnmapReadbackShm();
+        }
+      }
       mapped_buffer_range_map_.erase(iter);
     }
   }
@@ -5613,8 +5624,19 @@
 void GLES2Implementation::ClearMappedBufferRangeMap() {
   for (auto& buffer_range : mapped_buffer_range_map_) {
     if (!buffer_range.second.shm_memory.empty()) {
-      mapped_memory_->FreePendingToken(buffer_range.second.shm_memory.data(),
-                                       helper_->InsertToken());
+      if (buffer_range.second.shm_id != 0) {
+        // This was a normal transfer buffer allocation.
+        mapped_memory_->FreePendingToken(buffer_range.second.shm_memory.data(),
+                                         helper_->InsertToken());
+      } else {
+        // This was a shadow copy for readback. It's owned by the
+        // readback_buffer_shadow_tracker_, so we just need to unmap it.
+        auto* shadow_buffer =
+            readback_buffer_shadow_tracker_->GetBuffer(buffer_range.first);
+        if (shadow_buffer) {
+          shadow_buffer->UnmapReadbackShm();
+        }
+      }
     }
   }
   mapped_buffer_range_map_.clear();
@@ -6200,10 +6222,13 @@
     if (!buffer) {
       continue;
     }
-    int32_t shm_id = 0;
+    int32_t shm_id = -1;
     uint32_t shm_offset = 0;
     bool already_allocated = false;
     uint32_t size = buffer->Alloc(&shm_id, &shm_offset, &already_allocated);
+    if (shm_id == -1) {
+      continue;
+    }
     if (already_allocated) {
       SendErrorMessage(
           "performance warning: READ-usage buffer was written, then "
diff --git a/gpu/command_buffer/client/gles2_implementation_unittest.cc b/gpu/command_buffer/client/gles2_implementation_unittest.cc
index 3e4fde9..e7e8e081 100644
--- a/gpu/command_buffer/client/gles2_implementation_unittest.cc
+++ b/gpu/command_buffer/client/gles2_implementation_unittest.cc
@@ -436,6 +436,12 @@
     return gl_->max_extra_transfer_buffer_size_ > 0;
   }
 
+  void SetQueryProcessCount(QueryTracker::Query* q, int32_t count) {
+    q->info_.sync->process_count = count;
+  }
+
+  MappedMemoryManager* mapped_memory() { return gl_->mapped_memory_.get(); }
+
   static SharedMemoryLimits SharedMemoryLimitsForTesting() {
     SharedMemoryLimits limits;
     limits.command_buffer_size = kCommandBufferSizeBytes;
@@ -3922,6 +3928,232 @@
   UNSAFE_TODO(EXPECT_EQ(0, memcmp(&expected, commands_, sizeof(expected))));
 }
 
+// Test that UnmapBuffer on a readback buffer with a non-zero offset
+// doesn't erroneously free adjacent blocks in FencedAllocator.
+// This is a regression test for a use-after-free bug.
+TEST_F(GLES2ImplementationTest, UnmapBufferWithOffsetFreesCorrectBlock) {
+  // Create two readback buffers.
+  std::array<GLuint, 2> buffers;
+  gl_->GenBuffers(buffers.size(), buffers.data());
+
+  const GLsizeiptr kBufferSize = 64;
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[0]);
+  gl_->BufferData(GL_ARRAY_BUFFER, kBufferSize, nullptr, GL_STREAM_READ);
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[1]);
+  gl_->BufferData(GL_ARRAY_BUFFER, kBufferSize, nullptr, GL_STREAM_READ);
+
+  // Trigger shadow copy allocation by starting a readback query.
+  GLuint query;
+  gl_->GenQueriesEXT(1, &query);
+
+  // We need to satisfy the expectations for BeginQueryEXT
+  EXPECT_CALL(*command_buffer(), OnFlush()).Times(testing::AnyNumber());
+
+  gl_->BeginQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM, query);
+  gl_->EndQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM);
+
+  // Simulate query completion to update tracker serials.
+  QueryTracker::Query* q = GetQuery(query);
+  ASSERT_TRUE(q);
+  // Mark as processed by service
+  SetQueryProcessCount(q, q->submit_count());
+  // Trigger callback
+  bool flush_if_pending = false;
+  EXPECT_TRUE(q->CheckResultsAvailable(helper_, flush_if_pending));
+
+  // Map buffer 1 at offset 0.
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[1]);
+  void* addr2 = gl_->MapBufferRange(GL_ARRAY_BUFFER, 0, 1, GL_MAP_READ_BIT);
+  ASSERT_TRUE(addr2);
+
+  // Map buffer 0 with non-zero offset.
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[0]);
+  const GLintptr kOffset = 16;
+  void* addr1_with_offset =
+      gl_->MapBufferRange(GL_ARRAY_BUFFER, kOffset, 1, GL_MAP_READ_BIT);
+  ASSERT_TRUE(addr1_with_offset);
+
+  // Unmap buffer 0.
+  // If the bug exists, this will erroneously free the block for buffer 1
+  // because it calls FreePendingToken with addr1_with_offset, and
+  // FencedAllocator::GetBlockByOffset(16) will resolve to the next block
+  // (buffer 1).
+  gl_->UnmapBuffer(GL_ARRAY_BUFFER);
+
+  // Check if buffer 1's shadow memory was incorrectly freed.
+  int32_t token = 0;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/gpu/command_buffer/client/gles2_implementation_unittest.cc b/gpu/command_buffer/client/gles2_implementation_unittest.cc
index 3e4fde9..e7e8e081 100644
--- a/gpu/command_buffer/client/gles2_implementation_unittest.cc
+++ b/gpu/command_buffer/client/gles2_implementation_unittest.cc
@@ -436,6 +436,12 @@
     return gl_->max_extra_transfer_buffer_size_ > 0;
   }
 
+  void SetQueryProcessCount(QueryTracker::Query* q, int32_t count) {
+    q->info_.sync->process_count = count;
+  }
+
+  MappedMemoryManager* mapped_memory() { return gl_->mapped_memory_.get(); }
+
   static SharedMemoryLimits SharedMemoryLimitsForTesting() {
     SharedMemoryLimits limits;
     limits.command_buffer_size = kCommandBufferSizeBytes;
@@ -3922,6 +3928,232 @@
   UNSAFE_TODO(EXPECT_EQ(0, memcmp(&expected, commands_, sizeof(expected))));
 }
 
+// Test that UnmapBuffer on a readback buffer with a non-zero offset
+// doesn't erroneously free adjacent blocks in FencedAllocator.
+// This is a regression test for a use-after-free bug.
+TEST_F(GLES2ImplementationTest, UnmapBufferWithOffsetFreesCorrectBlock) {
+  // Create two readback buffers.
+  std::array<GLuint, 2> buffers;
+  gl_->GenBuffers(buffers.size(), buffers.data());
+
+  const GLsizeiptr kBufferSize = 64;
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[0]);
+  gl_->BufferData(GL_ARRAY_BUFFER, kBufferSize, nullptr, GL_STREAM_READ);
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[1]);
+  gl_->BufferData(GL_ARRAY_BUFFER, kBufferSize, nullptr, GL_STREAM_READ);
+
+  // Trigger shadow copy allocation by starting a readback query.
+  GLuint query;
+  gl_->GenQueriesEXT(1, &query);
+
+  // We need to satisfy the expectations for BeginQueryEXT
+  EXPECT_CALL(*command_buffer(), OnFlush()).Times(testing::AnyNumber());
+
+  gl_->BeginQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM, query);
+  gl_->EndQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM);
+
+  // Simulate query completion to update tracker serials.
+  QueryTracker::Query* q = GetQuery(query);
+  ASSERT_TRUE(q);
+  // Mark as processed by service
+  SetQueryProcessCount(q, q->submit_count());
+  // Trigger callback
+  bool flush_if_pending = false;
+  EXPECT_TRUE(q->CheckResultsAvailable(helper_, flush_if_pending));
+
+  // Map buffer 1 at offset 0.
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[1]);
+  void* addr2 = gl_->MapBufferRange(GL_ARRAY_BUFFER, 0, 1, GL_MAP_READ_BIT);
+  ASSERT_TRUE(addr2);
+
+  // Map buffer 0 with non-zero offset.
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[0]);
+  const GLintptr kOffset = 16;
+  void* addr1_with_offset =
+      gl_->MapBufferRange(GL_ARRAY_BUFFER, kOffset, 1, GL_MAP_READ_BIT);
+  ASSERT_TRUE(addr1_with_offset);
+
+  // Unmap buffer 0.
+  // If the bug exists, this will erroneously free the block for buffer 1
+  // because it calls FreePendingToken with addr1_with_offset, and
+  // FencedAllocator::GetBlockByOffset(16) will resolve to the next block
+  // (buffer 1).
+  gl_->UnmapBuffer(GL_ARRAY_BUFFER);
+
+  // Check if buffer 1's shadow memory was incorrectly freed.
+  int32_t token = 0;
+  FencedAllocator::State state2 =
+      mapped_memory()->GetPointerStatusForTest(addr2, &token);
+  EXPECT_EQ(FencedAllocator::IN_USE, state2);
+
+  // Clean up buffer 1
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[1]);
+  gl_->UnmapBuffer(GL_ARRAY_BUFFER);
+}
+
+// Test that deleting a buffer or clearing the mapping map correctly handles
+// shadow buffers without triggering misaligned frees.
+TEST_F(GLES2ImplementationTest, ReadbackShadowMixedCleanup) {
+  std::array<GLuint, 3> buffers;
+  gl_->GenBuffers(buffers.size(), buffers.data());
+
+  // Setup shadow buffers for all 3
+  for (auto buffer : buffers) {
+    gl_->BindBuffer(GL_ARRAY_BUFFER, buffer);
+    gl_->BufferData(GL_ARRAY_BUFFER, 64, nullptr, GL_STREAM_READ);
+  }
+
+  // Trigger shadow allocation
+  GLuint query;
+  gl_->GenQueriesEXT(1, &query);
+  EXPECT_CALL(*command_buffer(), OnFlush()).Times(testing::AnyNumber());
+  gl_->BeginQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM, query);
+  gl_->EndQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM);
+  QueryTracker::Query* q = GetQuery(query);
+  SetQueryProcessCount(q, q->submit_count());
+  bool flush_if_pending = false;
+  EXPECT_TRUE(q->CheckResultsAvailable(helper_, flush_if_pending));
+
+  // Create mixed mappings
+  // Buffer 0: Shadow, Offset 16
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[0]);
+  void* addr0 = gl_->MapBufferRange(GL_ARRAY_BUFFER, 16, 1, GL_MAP_READ_BIT);
+  ASSERT_TRUE(addr0);
+
+  // Buffer 1: Shadow, Offset 0
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[1]);
+  void* addr1 = gl_->MapBufferRange(GL_ARRAY_BUFFER, 0, 1, GL_MAP_READ_BIT);
+  ASSERT_TRUE(addr1);
+
+  // Buffer 2: Shadow, Offset 32
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[2]);
+  void* addr2 = gl_->MapBufferRange(GL_ARRAY_BUFFER, 32, 1, GL_MAP_READ_BIT);
+  ASSERT_TRUE(addr2);
+
+  // Test unmapping a shadow-mapped buffer with offset
+  // This calls RemoveMappedBufferRangeById(buffers[0]) via UnmapBuffer
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[0]);
+  gl_->UnmapBuffer(GL_ARRAY_BUFFER);
+
+  // Test unmapping remaining shadow-mapped buffers
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[1]);
+  gl_->UnmapBuffer(GL_ARRAY_BUFFER);
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffers[2]);
+  gl_->UnmapBuffer(GL_ARRAY_BUFFER);
+}
+
+// Test that ClearMappedBufferRangeMap correctly handles shadow-mapped buffers.
+TEST_F(GLES2ImplementationTest, ClearMappedBufferRangeMapShadow) {
+  GLuint buffer;
+  gl_->GenBuffers(1, &buffer);
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffer);
+  gl_->BufferData(GL_ARRAY_BUFFER, 64, nullptr, GL_STREAM_READ);
+
+  GLuint query;
+  gl_->GenQueriesEXT(1, &query);
+  EXPECT_CALL(*command_buffer(), OnFlush()).Times(testing::AnyNumber());
+  gl_->BeginQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM, query);
+  gl_->EndQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM);
+
+  // Simulate query completion
+  QueryTracker::Query* q = GetQuery(query);
+  ASSERT_TRUE(q);
+  SetQueryProcessCount(q, q->submit_count());
+  bool flush_if_pending = false;
+  EXPECT_TRUE(q->CheckResultsAvailable(helper_, flush_if_pending));
+
+  void* addr = gl_->MapBufferRange(GL_ARRAY_BUFFER, 0, 1, GL_MAP_READ_BIT);
+  ASSERT_TRUE(addr);
+}
+
+// Test that ClearMappedBufferMap correctly cleans up buffers mapped via
+// MapBufferSubDataCHROMIUM.
+TEST_F(GLES2ImplementationTest, ClearMappedBufferMap) {
+  GLuint buffer;
+  gl_->GenBuffers(1, &buffer);
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffer);
+  gl_->BufferData(GL_ARRAY_BUFFER, 64, nullptr, GL_STATIC_DRAW);
+
+  EXPECT_CALL(*command_buffer(), OnFlush()).Times(testing::AnyNumber());
+  void* addr =
+      gl_->MapBufferSubDataCHROMIUM(GL_ARRAY_BUFFER, 0, 1, GL_WRITE_ONLY);
+  ASSERT_TRUE(addr);
+}
+
+// Test that AllocateShadowCopiesForReadback skips buffers that have been
+// deleted while in the unfenced list.
+TEST_F(GLES2ImplementationTest, AllocateShadowCopiesForReadbackNullBuffer) {
+  GLuint buffer;
+  gl_->GenBuffers(1, &buffer);
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffer);
+  gl_->BufferData(GL_ARRAY_BUFFER, 64, nullptr, GL_STREAM_READ);
+
+  gl_->DeleteBuffers(1, &buffer);
+
+  GLuint query;
+  gl_->GenQueriesEXT(1, &query);
+  EXPECT_CALL(*command_buffer(), OnFlush()).Times(testing::AnyNumber());
+  gl_->BeginQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM, query);
+  // Prior to the fix, this would crash in AllocateShadowCopiesForReadback
+  // because it would dereference a null WeakPtr for the deleted buffer.
+}
+
+// Test that AllocateShadowCopiesForReadback correctly handles shadow buffer
+// allocation failures.
+TEST_F(GLES2ImplementationTest, AllocateShadowCopiesForReadbackAllocFail) {
+  GLuint buffer;
+  gl_->GenBuffers(1, &buffer);
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffer);
+  gl_->BufferData(GL_ARRAY_BUFFER, 64, nullptr, GL_STREAM_READ);
+
+  mapped_memory()->set_max_allocated_bytes(0);
+
+  GLuint query;
+  gl_->GenQueriesEXT(1, &query);
+  EXPECT_CALL(*command_buffer(), OnFlush()).Times(testing::AnyNumber());
+  gl_->BeginQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM, query);
+  // Prior to the fix, this would incorrectly attempt to issue a shadow
+  // allocation command with an invalid shared memory ID (-1).
+}
+
+// Test that AllocateShadowCopiesForReadback issues a performance warning if a
+// READ-usage buffer is written to again while a shadow copy is already
+// allocated.
+TEST_F(GLES2ImplementationTest,
+       AllocateShadowCopiesForReadbackAlreadyAllocated) {
+  GLuint buffer;
+  gl_->GenBuffers(1, &buffer);
+  gl_->BindBuffer(GL_ARRAY_BUFFER, buffer);
+  gl_->BufferData(GL_ARRAY_BUFFER, 64, nullptr, GL_STREAM_READ);
+
+  // Trigger first allocation
+  GLuint query1;
+  gl_->GenQueriesEXT(1, &query1);
+  EXPECT_CALL(*command_buffer(), OnFlush()).Times(testing::AnyNumber());
+  gl_->BeginQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM, query1);
+  gl_->EndQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM);
+
+  // Write again, adding back to unfenced list
+  const char data = 'a';
+  gl_->BufferSubData(GL_ARRAY_BUFFER, 0, 1, &data);
+
+  // Capture warning
+  std::string last_error;
+  gl_->SetErrorMessageCallback(
+      base::BindRepeating([](std::string* error, const char* message,
+                             int32_t id) { *error = message; },
+                          &last_error));
+
+  // Trigger second allocation
+  GLuint query2;
+  gl_->GenQueriesEXT(1, &query2);
+  gl_->BeginQueryEXT(GL_READBACK_SHADOW_COPIES_UPDATED_CHROMIUM, query2);
+
+  EXPECT_THAT(last_error,
+              testing::HasSubstr("READ-usage buffer was written, then fenced, "
+                                 "but written again"));
+}
+
 #include "gpu/command_buffer/client/gles2_implementation_unittest_autogen.h"
 
 }  // namespace gles2
diff --git a/gpu/command_buffer/client/readback_buffer_shadow_tracker_unittest.cc b/gpu/command_buffer/client/readback_buffer_shadow_tracker_unittest.cc
new file mode 100644
index 0000000..df174fcc
--- /dev/null
+++ b/gpu/command_buffer/client/readback_buffer_shadow_tracker_unittest.cc
@@ -0,0 +1,61 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "gpu/command_buffer/client/readback_buffer_shadow_tracker.h"
+
+#include <memory>
+
+#include "gpu/command_buffer/client/client_test_helper.h"
+#include "gpu/command_buffer/client/gles2_cmd_helper.h"
+#include "gpu/command_buffer/client/mapped_memory.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace gpu::gles2 {
+
+class ReadbackBufferShadowTrackerTest : public testing::Test {
+ protected:
+  void SetUp() override {
+    command_buffer_ = std::make_unique<MockClientCommandBuffer>();
+    command_buffer_->DelegateToFake();
+    helper_ = std::make_unique<GLES2CmdHelper>(command_buffer_.get());
+    helper_->Initialize(1024);
+    EXPECT_CALL(*command_buffer_, DestroyTransferBuffer(testing::_))
+        .Times(testing::AnyNumber());
+    mapped_memory_ = std::make_unique<MappedMemoryManager>(helper_.get(), 1024);
+    tracker_ = std::make_unique<ReadbackBufferShadowTracker>(
+        mapped_memory_.get(), helper_.get());
+  }
+
+  std::unique_ptr<gpu::MockClientCommandBuffer> command_buffer_;
+  std::unique_ptr<GLES2CmdHelper> helper_;
+  std::unique_ptr<MappedMemoryManager> mapped_memory_;
+  std::unique_ptr<ReadbackBufferShadowTracker> tracker_;
+};
+
+// Test that ReadbackBufferShadowTracker::Buffer::Alloc correctly handles
+// MappedMemoryManager::Alloc failures.
+TEST_F(ReadbackBufferShadowTrackerTest, AllocFails) {
+  const GLuint kBufferId = 1;
+  const GLuint kSize = 64;
+  tracker_->GetOrCreateBuffer(kBufferId, kSize);
+
+  int32_t shm_id = 0;
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Free in ReadbackBufferShadowTracker via getBufferSubData with Non-Zero Offset

Use-After-Free in ReadbackBufferShadowTracker via getBufferSubData with Non-Zero Offset

Summary

A use-after-free exists in the GPU command buffer client’s ReadbackBufferShadowTracker on all desktop platforms. When a WebGL2 application calls getBufferSubData with a non-zero srcByteOffset on a STREAM_READ buffer, the internal UnmapBuffer path frees the wrong block in the FencedAllocator, releasing an adjacent buffer’s shadow memory while it is still live. An attacker can reclaim the freed region to read or corrupt another buffer’s data. By further manipulating the mapped memory lifecycle so the entire backing chunk is destroyed, the dangling shadow pointer dereferences unmapped memory, crashing the renderer. This is exploitable from any WebGL2-capable page with no special permissions.

Bisect

Introducing Commit: 2ca03f3fc6fde691b46cba5744b31dff33c7725c

Root Cause

The ReadbackBufferShadowTracker maintains shadow copies of STREAM_READ, DYNAMIC_READ, and STATIC_READ buffers in shared memory, enabling getBufferSubData to return data without a GPU round-trip. When MapBufferRange is called on such a buffer, it calls MapReadbackShm(offset, size), which returns a subspan of the full allocation:

// readback_buffer_shadow_tracker.cc:47-66
base::span<uint8_t> ReadbackBufferShadowTracker::Buffer::MapReadbackShm(
    uint32_t offset,
    uint32_t map_size) {
  // ...
  is_mapped_ = true;
  return readback_buffer_.subspan(offset, map_size);
}

This subspan is stored in mapped_buffer_range_map_ keyed by buffer ID. When offset > 0, the span’s data() pointer points into the middle of the allocation, not at its base.

The corresponding UnmapBuffer correctly frees the allocation at its base address through UnmapReadbackShm, which internally calls Free():

// readback_buffer_shadow_tracker.cc:39-45, 68-74
void ReadbackBufferShadowTracker::Buffer::Free() {
  if (!readback_buffer_.empty()) {
    mapped_memory_->FreePendingToken(readback_buffer_.data(),
                                     helper_->InsertToken());
  }
  readback_buffer_ = {};
}

bool ReadbackBufferShadowTracker::Buffer::UnmapReadbackShm() {
  Free();
  bool was_mapped = is_mapped_;
  is_mapped_ = false;
  return was_mapped;
}

However, after UnmapReadbackShm returns, UnmapBuffer unconditionally calls RemoveMappedBufferRangeById, which frees the subspan pointer a second time:

// gles2_implementation.cc:5608-5617
bool was_mapped_by_readback_tracker = false;
if (auto* buffer_object =
        readback_buffer_shadow_tracker_->GetBuffer(buffer)) {
  was_mapped_by_readback_tracker = buffer_object->UnmapReadbackShm();
}
if (!was_mapped_by_readback_tracker) {
  helper_->UnmapBuffer(target);
  InvalidateReadbackBufferShadowDataCHROMIUM(GetBoundBufferHelper(target));
}
RemoveMappedBufferRangeById(buffer);  // unconditional second free

RemoveMappedBufferRangeById passes the subspan’s data() pointer to FreePendingToken:

// gles2_implementation.cc:5442-5452
void GLES2Implementation::RemoveMappedBufferRangeById(GLuint buffer) {
  if (buffer > 0) {
    auto iter = mapped_buffer_range_map_.find(buffer);
    if (iter != mapped_buffer_range_map_.end() &&
        !iter->second.shm_memory.empty()) {
      mapped_memory_->FreePendingToken(iter->second.shm_memory.data(),
                                       helper_->InsertToken());
      mapped_buffer_range_map_.erase(iter);
    }
  }
}

When srcByteOffset > 0, the subspan pointer does not correspond to the base of any allocation in the FencedAllocator. The allocator’s GetBlockByOffset uses lower_bound binary search to resolve the offset to a block index, and the only guard against a mismatch is DCHECK_EQ(block.offset, offset), which is stripped in release builds:

// fenced_allocator.cc:101-110
void FencedAllocator::FreePendingToken(FencedAllocator::Offset offset,
                                       int32_t token) {
  BlockIndex index = GetBlockByOffset(offset);
  Block &block = blocks_[index];
  DCHECK_EQ(block.offset, offset);  // stripped in release
  if (block.state == IN_USE)
    bytes_in_use_ -= block.size;
  block.state = FREE_PENDING_TOKEN;
  block.token = token;
}

The lower_bound search resolves to the next block after the intended allocation, since the subspan offset falls between two block boundaries. This erroneously marks the adjacent block as FREE_PENDING_TOKEN, freeing memory that belongs to a different, still-live buffer.

Once the adjacent block is freed, an attacker can allocate new buffers that reuse the same memory region, creating an overlapping allocation where two logical buffers share the same physical backing. Reading from the original (dangling) buffer returns the new buffer’s contents, demonstrating information disclosure. Writing to one buffer corrupts the other.

Reproduce

Notes on the PoC Structure and Flags

The vulnerability itself is triggerable from any WebGL2 page without any special flags or user interaction; in a normal release build, the same bug produces exploitable heap corruption silently. All flags in the launch command, including --disable-popup-blocking, exist only for reproducing the ASAN crash log. Specifically, --disable-popup-blocking is required because Phase 2 of the PoC uses window.open to force a tab switch, triggering the page visibility transition that unmaps the shared memory chunk and produces the ASAN-detectable access-violation.

The PoC has two phases because the FencedAllocator is a sub-allocator within a single shared memory mapping, and its internal block-level mis-free is invisible to ASAN. Phase 1 proves the vulnerability is real by demonstrating overlapping allocation: after the mis-free, a newly allocated buffer E reuses the erroneously freed block, and reading through the original buffer C returns E’s data (0xEE). The overlapping allocation from Phase 1 is already sufficient for exploitation, as an attacker can spray controlled data into the reclaimed region to corrupt adjacent buffer contents or hijack pointer-containing structures. Phase 2 exists purely to provide a minimal, deterministic ASAN crash as proof: it drains all other allocations from the chunk so bytes_in_use reaches zero, then triggers a page visibility transition that causes MappedMemoryManager to destroy the entire chunk via UnmapViewOfFile. The dangling readback_buffer_ span now points into unmapped virtual address space, and getBufferSubData crashes in memcpy.

Steps

Tested on commit 4e910e2277470c4576177b37937569fa4151abdc, Windows 11. No source patches are required.

Build out/asan-release with args.gn:

is_debug = false
dcheck_always_on = false
is_asan = true
is_component_build = false

Build and launch:

autoninja -C out/asan-release chrome
python3 -m http.server 8080 -d issue_blink_mod_010
out\asan-release\chrome.exe --disable-gpu-sandbox --no-sandbox --disable-popup-blocking --enable-logging=stderr http://localhost:8080/poc.html

ASAN log:

==13076==ERROR: AddressSanitizer: access-violation on unknown address 0x132df6bb0c80 (pc 0x7ff92936dc7d bp 0x00ae447fcb30 sp 0x00ae447fcaa8 T0)
==13076==The signal is caused by a READ memory access.
    #0 0x7ff92936dc7c in memcpy+0x17c (C:\WINDOWS\System32\ucrtbase.dll+0x1800edc7c)
    #1 0x7ff8d343b532 in _asan_memcpy+0x422 (D:\chromium\src\out\asan-release\clang_rt.asan_dynamic-x86_64.dll+0x18004b532)
    #2 0x7ff8556ac923 in blink::WebGL2RenderingContextBase::getBufferSubData D:\chromium\src\third_party\blink\renderer\modules\webgl\webgl2_rendering_context_base.cc:456
    #3 0x7ff8556fe4cb in blink::`anonymous namespace'::v8_webgl2_rendering_context::GetBufferSubDataOperationCallback D:\chromium\src\out\asan-release\gen\third_party\blink\renderer\bindings\modules\v8\v8_webgl2_rendering_context.cc:4154
    #4 0x7ff85a7647e4 in Builtins_CallApiCallbackGeneric+0xa4 (D:\chromium\src\out\asan-release\chrome.dll+0x1ada747e4)
    #5 0x7ff85a76293b in Builtins_InterpreterEntryTrampoline+0x13b (D:\chromium\src\out\asan-release\chrome.dll+0x1ada7293b)
    #6 0x7ff85a75f6db in Builtins_JSEntryTrampoline+0x5b (D:\chromium\src\out\asan-release\chrome.dll+0x1ada6f6db)
    #7 0x7ff85a75f23e in Builtins_JSEntry+0xfe (D:\chromium\src\out\asan-release\chrome.dll+0x1ada6f23e)
    #8 0x7ff8322b2222 in v8::internal::`anonymous namespace'::Invoke D:\chromium\src\v8\src\execution\execution.cc:474
    #9 0x7ff8322b0493 in v8::internal::Execution::Call D:\chromium\src\v8\src\execution\execution.cc:564
    #10 0x7ff831db389c in v8::Function::Call D:\chromium\src\v8\src\api\api.cc:5584
    #11 0x7ff848d77fdd in blink::V8ScriptRunner::CallFunction D:\chromium\src\third_party\blink\renderer\bindings\core\v8\v8_script_runner.cc:851
    #12 0x7ff853c92406 in blink::bindings::CallbackInvokeHelper<blink::CallbackInterfaceBase,0,0>::Call D:\chromium\src\third_party\blink\renderer\bindings\core\v8\callback_invoke_helper.cc:148
    #13 0x7ff8538248df in blink::V8EventListener::InvokeWithoutRunnabilityCheck D:\chromium\src\out\asan-release\gen\third_party\blink\renderer\bindings\core\v8\v8_event_listener.cc:119
    #14 0x7ff84e982abb in blink::JSEventListener::InvokeInternal D:\chromium\src\third_party\blink\renderer\bindings\core\v8\js_event_listener.cc:58
    #15 0x7ff84e954feb in blink::JSBasedEventListener::Invoke D:\chromium\src\third_party\blink\renderer\bindings\core\v8\js_based_event_listener.cc:193
    #16 0x7ff848ee8c56 in blink::EventTarget::FireEventListeners D:\chromium\src\third_party\blink\renderer\core\dom\events\event_target.cc:1081
    #17 0x7ff848ee6a02 in blink::EventTarget::FireEventListeners D:\chromium\src\third_party\blink\renderer\core\dom\events\event_target.cc:982
    #18 0x7ff84e80209f in blink::EventDispatcher::DispatchEventAtBubbling D:\chromium\src\third_party\blink\renderer\core\dom\events\event_dispatcher.cc:368
    #19 0x7ff84e800a0e in blink::EventDispatcher::Dispatch D:\chromium\src\third_party\blink\renderer\core\dom\events\event_dispatcher.cc:278
    #20 0x7ff84e7fef51 in blink::EventDispatcher::DispatchEvent D:\chromium\src\third_party\blink\renderer\core\dom\events\event_dispatcher.cc:79
    #21 0x7ff848a48f5a in blink::Document::DidChangeVisibilityState D:\chromium\src\third_party\blink\renderer\core\dom\document.cc:2275
    #22 0x7ff8489bab61 in blink::LocalFrame::DidChangeVisibilityState D:\chromium\src\third_party\blink\renderer\core\frame\local_frame.cc:1175
    #23 0x7ff848c68d56 in blink::Page::SetVisibilityState D:\chromium\src\third_party\blink\renderer\core\page\page.cc:854
    #24 0x7ff8488e5e41 in blink::WebViewImpl::SetVisibilityState D:\chromium\src\third_party\blink\renderer\core\exported\web_view_impl.cc:4189
    #25 0x7ff848902757 in blink::WebViewImpl::SetPageLifecycleStateInternal D:\chromium\src\third_party\blink\renderer\core\exported\web_view_impl.cc:2551
    #26 0x7ff848903fd4 in blink::WebViewImpl::SetPageLifecycleState D:\chromium\src\third_party\blink\renderer\core\exported\web_view_impl.cc:2473
    #27 0x7ff83a218c88 in blink::mojom::blink::PageBroadcastStubDispatch::AcceptWithResponder D:\chromium\src\out\asan-release\gen\third_party\blink\public\mojom\page\page.mojom-blink.cc:1950
    #28 0x7ff84891d390 in blink::mojom::blink::PageBroadcastStub<mojo::RawPtrImplRefTraits<blink::mojom::blink::PageBroadcast> >::AcceptWithResponder D:\chromium\src\out\asan-release\gen\third_party\blink\public\mojom\page\page.mojom-blink.h:248
    #29 0x7ff8405deefd in mojo::InterfaceEndpointClient::HandleValidatedMessage D:\chromium\src\mojo\public\cpp\bindings\lib\interface_endpoint_client.cc:1036
    #30 0x7ff845e8488d in mojo::MessageDispatcher::Accept D:\chromium\src\mojo\public\cpp\bindings\lib\message_dispatcher.cc:44
    #31 0x7ff8405e563e in mojo::InterfaceEndpointClient::HandleIncomingMessage D:\chromium\src\mojo\public\cpp\bindings\lib\interface_endpoint_client.cc:747
    #32 0x7ff846ee4166 in IPC::ChannelAssociatedGroupController::AcceptOnEndpointThread D:\chromium\src\ipc\ipc_mojo_bootstrap.cc:1199
    #33 0x7ff846ee66a1 in base::internal::Invoker<...>::RunOnce D:\chromium\src\base\functional\bind_internal.h:982
    #34 0x7ff8407c3d48 in base::TaskAnnotator::RunTaskImpl D:\chromium\src\base\task\common\task_annotator.cc:229
    #35 0x7ff845f2c531 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl D:\chromium\src\base\task\sequence_manager\thread_controller_with_message_pump_impl.cc:475
    #36 0x7ff845f2b393 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork D:\chromium\src\base\task\sequence_manager\thread_controller_with_message_pump_impl.cc:346
    #37 0x7ff845f73c37 in base::MessagePumpDefault::Run D:\chromium\src\base\message_loop\message_pump_default.cc:42
    #38 0x7ff845f2e27f in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::Run D:\chromium\src\base\task\sequence_manager\thread_controller_with_message_pump_impl.cc:650
    #39 0x7ff840836dbc in base::RunLoop::Run D:\chromium\src\base\run_loop.cc:135
    #40 0x7ff843e2043f in content::RendererMain D:\chromium\src\content\renderer\renderer_main.cc:332
    #41 0x7ff83d18594f in content::RunOtherNamedProcessTypeMain D:\chromium\src\content\app\content_main_runner_impl.cc:762
    #42 0x7ff83d1880bb in content::ContentMainRunnerImpl::Run D:\chromium\src\content\app\content_main_runner_impl.cc:1152
    #43 0x7ff83d17beaf in content::RunContentProcess D:\chromium\src\content\app\content_main.cc:358
    #44 0x7ff83d17c652 in content::ContentMain D:\chromium\src\content\app\content_main.cc:371
    #45 0x7ff82ccf2b06 in ChromeMain D:\chromium\src\chrome\app\chrome_main.cc:191
    #46 0x7ff710b04807 in MainDllLoader::Launch D:\chromium\src\chrome\app\main_dll_loader_win.cc:204
    #47 0x7ff710b02074 in main D:\chromium\src\chrome\app\chrome_exe_main_win.cc:351

AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: access-violation (C:\WINDOWS\System32\ucrtbase.dll+0x1800edc7c) in memcpy+0x17c
==13076==ABORTING

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker