Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in GPU
DescriptionUse after free in GPU
ComponentGPU
Bug ClassUAF
Tracker498715368
Fix commitdaeb9ba8d7ce (chromium/src) +89/-17
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-12

Changed Functions

FunctionChangeNotes
that modified
if
gpu/command_buffer/service/buffer_manager.cc
modified
if
gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc
modified
TEST_P
gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc
modified

Files Changed

  • gpu/command_buffer/service/buffer_manager.cc
  • gpu/command_buffer/service/buffer_manager.h
  • gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc
  • gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc
From daeb9ba8d7ce0f5c610f14d36318909b20c09b4f Mon Sep 17 00:00:00 2001
From: Shrek Shao <shrekshao@google.com>
Date: Fri, 03 Apr 2026 15:00:47 -0700
Subject: [PATCH] [GPU] Fix UAF by clearing buffer mapping on OOM during glBufferData.

Per ES 3.0 spec, glBufferData implicitly unmaps any mapped range before
deleting the data store, even if the subsequent allocation fails.
Previously, the validating decoder did not clear its internal mapping
state on OOM, leading to a stale pointer that could be used in a
subsequent glUnmapBuffer call.

This CL adds Buffer::ClearMapping() to centralize clearing of mapping
and readback state, and calls it in the OOM path of DoBufferData.
It also updates the buffer size to 0 on OOM to match driver state.

Additionally, it fixes a stale mock expectation in the unit test base
class that was breaking tests on Linux.

Bug: 498715368
Test: gpu_unittests --gtest_filter=Service/GLES3DecoderTest.BufferDataOOMOnMappedBufferLeavesStaleMappedRange/0 with ASAN
Change-Id: Id1b4ebc06a4aabb68dbe635825e2ecbddd099093
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7727794
Reviewed-by: Zhenyao Mo <zmo@chromium.org>
Commit-Queue: Shrek Shao <shrekshao@google.com>
Auto-Submit: Shrek Shao <shrekshao@google.com>
Cr-Commit-Position: refs/heads/main@{#1609981}
---

diff --git a/gpu/command_buffer/service/buffer_manager.cc b/gpu/command_buffer/service/buffer_manager.cc
index 109ef77..17725e5 100644
--- a/gpu/command_buffer/service/buffer_manager.cc
+++ b/gpu/command_buffer/service/buffer_manager.cc
@@ -199,9 +199,7 @@
   DCHECK_EQ(shadow_.size(), static_cast<size_t>(use_shadow ? size : 0u));
   size_ = size;
 
-  mapped_range_.reset(nullptr);
-  readback_shm_ = nullptr;
-  readback_shm_offset_ = 0;
+  ClearMapping();
 }
 
 bool Buffer::CheckRange(GLintptr offset, GLsizeiptr size) const {
@@ -361,6 +359,12 @@
   mapped_range_.reset(nullptr);
 }
 
+void Buffer::ClearMapping() {
+  mapped_range_.reset(nullptr);
+  readback_shm_ = nullptr;
+  readback_shm_offset_ = 0;
+}
+
 void Buffer::SetReadbackShadowAllocation(scoped_refptr<gpu::Buffer> shm,
                                          uint32_t shm_offset) {
   DCHECK(shm);
@@ -504,10 +508,20 @@
   GLenum error = ERRORSTATE_PEEK_GL_ERROR(error_state, "glBufferData");
   if (error != GL_NO_ERROR) {
     DCHECK_EQ(static_cast<GLenum>(GL_OUT_OF_MEMORY), error);
-    size = 0;
     // TODO(zmo): This doesn't seem correct. There might be shadow data from
     // a previous successful BufferData() call.
     buffer->StageShadow(false, 0, nullptr);  // Also clear the shadow.
+    // SECURITY: Per ES 3.0.6 spec section 2.10.3.1, "Buffers are implicitly
+    // unmapped as a side effect of deletion or reinitialization (i.e. calling
+    // DeleteBuffers or BufferData)." The pointer in mapped_range_ is now
+    // stale, even if the subsequent allocation fails. Clear it here so that
+    // UnmapBufferHelper does not memcpy renderer-controlled SHM into freed
+    // driver memory. The success path below clears it via SetInfo().
+    buffer->ClearMapping();
+
+    // Since the driver deleted the existing data store, we must update our
+    // internal state to match.
+    buffer->size_ = 0;
     return;
   }
 
diff --git a/gpu/command_buffer/service/buffer_manager.h b/gpu/command_buffer/service/buffer_manager.h
index 5df5ea4..46862d15 100644
--- a/gpu/command_buffer/service/buffer_manager.h
+++ b/gpu/command_buffer/service/buffer_manager.h
@@ -102,6 +102,7 @@
                       void* pointer, scoped_refptr<gpu::Buffer> shm,
                       unsigned int shm_offset);
   void RemoveMappedRange();
+  void ClearMapping();
   const MappedRange* GetMappedRange() const {
     return mapped_range_.get();
   }
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc
index 1374b4c..c610b0d 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc
@@ -399,22 +399,20 @@
       .Times(1)
       .RetiresOnSaturation();
 
-  // TODO(boliu): Remove OS_ANDROID once crbug.com/259023 is fixed and the
-  // workaround has been reverted.
-#if !BUILDFLAG(IS_ANDROID)
-  if (normalized_init.has_alpha && !normalized_init.request_alpha) {
-    EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 1)).Times(1).RetiresOnSaturation();
-  }
+  if (surface_->GetHandle()) {
+    if (normalized_init.has_alpha && !normalized_init.request_alpha) {
+      EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 1)).Times(1).RetiresOnSaturation();
+    }
 
-  EXPECT_CALL(*gl_, Clear(
-      GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT))
-      .Times(1)
-      .RetiresOnSaturation();
+    EXPECT_CALL(*gl_, Clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT |
+                            GL_STENCIL_BUFFER_BIT))
+        .Times(1)
+        .RetiresOnSaturation();
 
-  if (normalized_init.has_alpha && !normalized_init.request_alpha) {
-    EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 0)).Times(1).RetiresOnSaturation();
+    if (normalized_init.has_alpha && !normalized_init.request_alpha) {
+      EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 0)).Times(1).RetiresOnSaturation();
+    }
   }
-#endif
 
   if (init.context_type == CONTEXT_TYPE_WEBGL2 &&
       group_->feature_info()->gl_version_info().is_es3) {
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc
index f7dd0c0..711d7be 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc
@@ -722,5 +722,64 @@
   }
 }
 
+TEST_P(GLES3DecoderTest, BufferDataOOMOnMappedBufferLeavesStaleMappedRange) {
+  const GLenum kTarget = GL_ARRAY_BUFFER;
+  const GLsizeiptr kSize = 64;
+
+  DoBindBuffer(kTarget, client_buffer_id_, kServiceBufferId);
+  DoBufferData(kTarget, kSize);
+
+  // 1. MapBufferRange
+  const GLbitfield kAccess = GL_MAP_WRITE_BIT;
+  uint32_t result_shm_id = shared_memory_id_;
+  uint32_t result_shm_offset = kSharedMemoryOffset;
+  uint32_t data_shm_id = shared_memory_id_;
+  uint32_t data_shm_offset = kSharedMemoryOffset + sizeof(uint32_t);
+
+  auto driver_mapped_mem = std::make_unique<int8_t[]>(kSize);
+
+  EXPECT_CALL(*gl_,
+              MapBufferRange(kTarget, 0, kSize, kAccess | GL_MAP_READ_BIT))
+      .WillOnce(Return(driver_mapped_mem.get()));
+
+  cmds::MapBufferRange cmd;
+  cmd.Init(kTarget, 0, kSize, kAccess, data_shm_id, data_shm_offset,
+           result_shm_id, result_shm_offset);
+  EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
+  EXPECT_EQ(GL_NO_ERROR, GetGLError());
+
+  Buffer* buffer = GetBuffer(client_buffer_id_);
+  ASSERT_NE(buffer, nullptr);
+  ASSERT_NE(buffer->GetMappedRange(), nullptr);
+
+  // 2. BufferData while mapped triggers OOM
+  // Per ES 3.0 spec, the driver implicitly unmaps the buffer.
+  EXPECT_CALL(*gl_, BufferData(kTarget, kSize, _, GL_STREAM_DRAW))
+      .WillOnce(testing::InvokeWithoutArgs([&driver_mapped_mem]() {
+        // Driver unmaps.
+        driver_mapped_mem.reset();
+      }));
+  EXPECT_CALL(*gl_, GetError())
+      .WillOnce(Return(static_cast<GLenum>(GL_OUT_OF_MEMORY)))
+      .WillRepeatedly(Return(static_cast<GLenum>(GL_NO_ERROR)));
+
+  cmds::BufferData buffer_data_cmd;
+  buffer_data_cmd.Init(kTarget, kSize, 0, 0, GL_STREAM_DRAW);
+  EXPECT_EQ(error::kNoError, ExecuteCmd(buffer_data_cmd));
+  EXPECT_EQ(static_cast<GLint>(GL_OUT_OF_MEMORY), GetGLError());
+
+  // 3. UnmapBuffer
+  // SECURITY: If fixed, buffer->GetMappedRange() should be null now.
+  // If not fixed, the following will trigger ASAN UAF in UnmapBufferHelper
+  // when it tries to memcpy into driver_mapped_mem.get().
+  EXPECT_EQ(buffer->GetMappedRange(), nullptr);
+  if (buffer->GetMappedRange() != nullptr) {
+    EXPECT_CALL(*gl_, UnmapBuffer(kTarget)).WillOnce(Return(GL_TRUE));
+    cmds::UnmapBuffer unmap_cmd;
+    unmap_cmd.Init(kTarget);
+    EXPECT_EQ(error::kNoError, ExecuteCmd(unmap_cmd));
+  }
+}
+
 }  // namespace gles2
 }  // namespace gpu
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc
index 1374b4c..c610b0d 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_base.cc
@@ -399,22 +399,20 @@
       .Times(1)
       .RetiresOnSaturation();
 
-  // TODO(boliu): Remove OS_ANDROID once crbug.com/259023 is fixed and the
-  // workaround has been reverted.
-#if !BUILDFLAG(IS_ANDROID)
-  if (normalized_init.has_alpha && !normalized_init.request_alpha) {
-    EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 1)).Times(1).RetiresOnSaturation();
-  }
+  if (surface_->GetHandle()) {
+    if (normalized_init.has_alpha && !normalized_init.request_alpha) {
+      EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 1)).Times(1).RetiresOnSaturation();
+    }
 
-  EXPECT_CALL(*gl_, Clear(
-      GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT))
-      .Times(1)
-      .RetiresOnSaturation();
+    EXPECT_CALL(*gl_, Clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT |
+                            GL_STENCIL_BUFFER_BIT))
+        .Times(1)
+        .RetiresOnSaturation();
 
-  if (normalized_init.has_alpha && !normalized_init.request_alpha) {
-    EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 0)).Times(1).RetiresOnSaturation();
+    if (normalized_init.has_alpha && !normalized_init.request_alpha) {
+      EXPECT_CALL(*gl_, ClearColor(0, 0, 0, 0)).Times(1).RetiresOnSaturation();
+    }
   }
-#endif
 
   if (init.context_type == CONTEXT_TYPE_WEBGL2 &&
       group_->feature_info()->gl_version_info().is_es3) {
diff --git a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc
index f7dd0c0..711d7be 100644
--- a/gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc
+++ b/gpu/command_buffer/service/gles2_cmd_decoder_unittest_buffers.cc
@@ -722,5 +722,64 @@
   }
 }
 
+TEST_P(GLES3DecoderTest, BufferDataOOMOnMappedBufferLeavesStaleMappedRange) {
+  const GLenum kTarget = GL_ARRAY_BUFFER;
+  const GLsizeiptr kSize = 64;
+
+  DoBindBuffer(kTarget, client_buffer_id_, kServiceBufferId);
+  DoBufferData(kTarget, kSize);
+
+  // 1. MapBufferRange
+  const GLbitfield kAccess = GL_MAP_WRITE_BIT;
+  uint32_t result_shm_id = shared_memory_id_;
+  uint32_t result_shm_offset = kSharedMemoryOffset;
+  uint32_t data_shm_id = shared_memory_id_;
+  uint32_t data_shm_offset = kSharedMemoryOffset + sizeof(uint32_t);
+
+  auto driver_mapped_mem = std::make_unique<int8_t[]>(kSize);
+
+  EXPECT_CALL(*gl_,
+              MapBufferRange(kTarget, 0, kSize, kAccess | GL_MAP_READ_BIT))
+      .WillOnce(Return(driver_mapped_mem.get()));
+
+  cmds::MapBufferRange cmd;
+  cmd.Init(kTarget, 0, kSize, kAccess, data_shm_id, data_shm_offset,
+           result_shm_id, result_shm_offset);
+  EXPECT_EQ(error::kNoError, ExecuteCmd(cmd));
+  EXPECT_EQ(GL_NO_ERROR, GetGLError());
+
+  Buffer* buffer = GetBuffer(client_buffer_id_);
+  ASSERT_NE(buffer, nullptr);
+  ASSERT_NE(buffer->GetMappedRange(), nullptr);
+
+  // 2. BufferData while mapped triggers OOM
+  // Per ES 3.0 spec, the driver implicitly unmaps the buffer.
+  EXPECT_CALL(*gl_, BufferData(kTarget, kSize, _, GL_STREAM_DRAW))
+      .WillOnce(testing::InvokeWithoutArgs([&driver_mapped_mem]() {
+        // Driver unmaps.
+        driver_mapped_mem.reset();
+      }));
+  EXPECT_CALL(*gl_, GetError())
+      .WillOnce(Return(static_cast<GLenum>(GL_OUT_OF_MEMORY)))
+      .WillRepeatedly(Return(static_cast<GLenum>(GL_NO_ERROR)));
+
+  cmds::BufferData buffer_data_cmd;
+  buffer_data_cmd.Init(kTarget, kSize, 0, 0, GL_STREAM_DRAW);
+  EXPECT_EQ(error::kNoError, ExecuteCmd(buffer_data_cmd));
+  EXPECT_EQ(static_cast<GLint>(GL_OUT_OF_MEMORY), GetGLError());
+
+  // 3. UnmapBuffer
+  // SECURITY: If fixed, buffer->GetMappedRange() should be null now.
+  // If not fixed, the following will trigger ASAN UAF in UnmapBufferHelper
+  // when it tries to memcpy into driver_mapped_mem.get().
+  EXPECT_EQ(buffer->GetMappedRange(), nullptr);
+  if (buffer->GetMappedRange() != nullptr) {
+    EXPECT_CALL(*gl_, UnmapBuffer(kTarget)).WillOnce(Return(GL_TRUE));
+    cmds::UnmapBuffer unmap_cmd;
+    unmap_cmd.Init(kTarget);
+    EXPECT_EQ(error::kNoError, ExecuteCmd(unmap_cmd));
+  }
+}
+
 }  // namespace gles2
 }  // namespace gpu
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential UAF write in GPU process via glBufferData OOM on Android

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: A potential Use-After-Free (UAF) write vulnerability exists in the validating command decoder in the GPU process. When a mapped buffer triggers an out-of-memory error during glBufferData, the buffer is implicitly unmapped by the OpenGL driver, but Chrome fails to update its internal state. A subsequent unmap operation then copies attacker-controlled data into the dangling driver pointer, leading to memory corruption.

Affected files:

  • gpu/command_buffer/service/buffer_manager.cc
  • gpu/command_buffer/service/gles2_cmd_decoder.cc
  • gpu/command_buffer/service/buffer_manager.h

Estimated timestamp from git blame: 2025-11-28

Summary

A potential Use-After-Free (UAF) write vulnerability has been identified in the validating command decoder’s buffer management logic, which is the default on Android.

The issue arises when glBufferData is called on a buffer that is currently mapped. According to the OpenGL ES 3.0 specification, this operation implicitly unmaps the buffer. However, if the glBufferData call fails with GL_OUT_OF_MEMORY, Chrome’s error handling returns early and fails to clear its internal mapping state, leaving a dangling pointer that can later be written to by an attacker.

Technical Details

In gpu/command_buffer/service/buffer_manager.cc, the BufferManager::DoBufferData function handles the glBufferData command:

  1. It invokes the underlying GL API’s glBufferData (e.g., at line 498).
  2. According to GLES 3.0 specs, calling glBufferData on a mapped buffer deletes the existing data store and implicitly unmaps the buffer. The memory previously pointed to by mapped_range_->pointer is freed by the GL driver.
  3. Chrome checks for errors using ERRORSTATE_PEEK_GL_ERROR (line 504).
  4. If a GL_OUT_OF_MEMORY error is detected, the function performs an early return (line 511).
  5. This early return bypasses the call to SetInfo (line 514). SetInfo eventually calls Buffer::SetInfo, which is responsible for executing mapped_range_.reset(nullptr); to clear the mapping state.

Because mapped_range_ is not cleared on OOM, Chrome continues to hold a dangling pointer to the memory address previously provided by the driver.

A subsequent call to glUnmapBuffer or glFlushMappedBufferRange will trigger GLES2DecoderImpl::UnmapBufferHelper (or DoFlushMappedBufferRange) in gpu/command_buffer/service/gles2_cmd_decoder.cc. This function retrieves the dangling mapped_range->pointer and executes a memcpy from renderer-controlled shared memory into that address (around line 16698):

UNSAFE_TODO(memcpy(mapped_range->pointer, mem, mapped_range->size));

Impact and Reachability

Because the pointer in question is allocated by the external GPU driver and not by PartitionAlloc, MiraclePtr (BackupRefPtr) does not provide protection against this UAF, despite the DanglingUntriaged annotation.

This vulnerability provides a compromised renderer with a controlled memcpy primitive into the GPU process’s memory space. By grooming the driver’s memory allocator, an attacker can overwrite critical GPU process or driver data, potentially leading to Remote Code Execution (RCE) and a full sandbox escape on Android (where the GPU process is often unsandboxed).

Potential Reproduction Steps

Note: These are suggested steps; our tooling has not executed a working proof of concept to verify them.

  1. From a compromised renderer, initialize a GLES3 context on Android using the validating decoder.
  2. Bind a buffer and map it using glMapBufferRange with write access.
  3. Call glBufferData on the mapped buffer with a size large enough to trigger GL_OUT_OF_MEMORY in the driver (but below the 1GB limit enforced by Chrome’s kDefaultMaxBufferSize).
  4. The OpenGL driver frees the mapped memory, but Chrome retains the pointer.
  5. Perform heap grooming by allocating new textures or buffers to reclaim the freed memory block.
  6. Call glUnmapBuffer for the implicitly unmapped buffer. Chrome will copy attacker-controlled data from shared memory into the newly allocated object.

Suggested Fix

In BufferManager::DoBufferData (gpu/command_buffer/service/buffer_manager.cc), ensure that SetInfo is called, or the buffer’s mapping state is explicitly cleared, even if an error like GL_OUT_OF_MEMORY is returned. Alternatively, validate that the buffer is not mapped before proceeding with glBufferData, similar to the check performed in ValidateAndDoBufferSubData.

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